mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
10 Commits
renovate/r
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cab50750f | ||
|
|
d795e82581 | ||
|
|
e7161bc9ab | ||
|
|
8e74363f32 | ||
|
|
1cb28788d6 | ||
|
|
ff9f855d4c | ||
|
|
13df2d1077 | ||
|
|
8389404975 | ||
|
|
cd920e2d84 | ||
|
|
92a11c18e0 |
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@fdd4d3ddf614fbcd8c29e4b106d3bbe0cb2c605d # v6.0.1
|
- uses: amannn/action-semantic-pull-request@7f33ba792281b034f64e96f4c0b5496782dd3b37 # v6.1.0
|
||||||
id: lint_pr_title
|
id: lint_pr_title
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -181,6 +181,12 @@ Thanks to the following contributors for their significant contributions:
|
|||||||
<br /><sub><b>@topiga</b></sub>
|
<br /><sub><b>@topiga</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/lancechant">
|
||||||
|
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@lancechant</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
@@ -213,6 +219,12 @@ Thanks to the following contributors for their significant contributions:
|
|||||||
<br /><sub><b>@whoopsi-daisy</b></sub>
|
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Gauvino">
|
||||||
|
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Gauvino</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.31.0",
|
"version": "0.32.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 59,
|
"versionCode": 62,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useAtomValue } from "jotai";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||||
|
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -98,7 +98,7 @@ export default function page() {
|
|||||||
/** Playback position in ticks. */
|
/** Playback position in ticks. */
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
const [_settings] = useSettings();
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
const playbackManager = usePlaybackManager();
|
const playbackManager = usePlaybackManager();
|
||||||
@@ -280,11 +280,15 @@ export default function page() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
|
// Update URL with final playback position before stopping
|
||||||
|
router.setParams({
|
||||||
|
playbackPosition: msToTicks(progress.get()).toString(),
|
||||||
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.stop();
|
videoRef.current?.stop();
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
}, [videoRef, reportPlaybackStopped, progress]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
@@ -293,7 +297,7 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
}, [navigation, stop]);
|
}, [navigation, stop]);
|
||||||
|
|
||||||
const currentPlayStateInfo = () => {
|
const currentPlayStateInfo = useCallback(() => {
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
return {
|
return {
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
@@ -309,7 +313,32 @@ export default function page() {
|
|||||||
repeatMode: RepeatMode.RepeatNone,
|
repeatMode: RepeatMode.RepeatNone,
|
||||||
playbackOrder: PlaybackOrder.Default,
|
playbackOrder: PlaybackOrder.Default,
|
||||||
};
|
};
|
||||||
};
|
}, [
|
||||||
|
stream,
|
||||||
|
item?.Id,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
progress,
|
||||||
|
isPlaying,
|
||||||
|
isMuted,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lastUrlUpdateTime = useSharedValue(0);
|
||||||
|
const wasJustSeeking = useSharedValue(false);
|
||||||
|
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||||
|
|
||||||
|
// Track when seeking ends to update URL immediately
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => isSeeking.get(),
|
||||||
|
(currentSeeking, previousSeeking) => {
|
||||||
|
if (previousSeeking && !currentSeeking) {
|
||||||
|
// Seeking just ended
|
||||||
|
wasJustSeeking.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: ProgressUpdatePayload) => {
|
||||||
@@ -322,10 +351,20 @@ export default function page() {
|
|||||||
|
|
||||||
progress.set(currentTime);
|
progress.set(currentTime);
|
||||||
|
|
||||||
// Update the playback position in the URL.
|
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||||
router.setParams({
|
const now = Date.now();
|
||||||
playbackPosition: msToTicks(currentTime).toString(),
|
const shouldUpdateUrl = wasJustSeeking.get();
|
||||||
});
|
wasJustSeeking.value = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldUpdateUrl ||
|
||||||
|
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
|
||||||
|
) {
|
||||||
|
router.setParams({
|
||||||
|
playbackPosition: msToTicks(currentTime).toString(),
|
||||||
|
});
|
||||||
|
lastUrlUpdateTime.value = now;
|
||||||
|
}
|
||||||
|
|
||||||
if (!item?.Id) return;
|
if (!item?.Id) return;
|
||||||
|
|
||||||
@@ -398,6 +437,7 @@ export default function page() {
|
|||||||
console.error("Error toggling mute:", error);
|
console.error("Error toggling mute:", error);
|
||||||
}
|
}
|
||||||
}, [previousVolume]);
|
}, [previousVolume]);
|
||||||
|
|
||||||
const volumeDownCb = useCallback(async () => {
|
const volumeDownCb = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -512,7 +552,7 @@ export default function page() {
|
|||||||
/** Whether the stream we're playing is not transcoding*/
|
/** Whether the stream we're playing is not transcoding*/
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
/** The initial options to pass to the VLC Player */
|
/** The initial options to pass to the VLC Player */
|
||||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
const initOptions = [``];
|
||||||
if (
|
if (
|
||||||
chosenSubtitleTrack &&
|
chosenSubtitleTrack &&
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||||
@@ -537,6 +577,54 @@ export default function page() {
|
|||||||
return () => setIsMounted(false);
|
return () => setIsMounted(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Memoize video ref functions to prevent unnecessary re-renders
|
||||||
|
const startPictureInPicture = useMemo(
|
||||||
|
() => videoRef.current?.startPictureInPicture,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const play = useMemo(
|
||||||
|
() => videoRef.current?.play || (() => {}),
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const pause = useMemo(
|
||||||
|
() => videoRef.current?.pause || (() => {}),
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const seek = useMemo(
|
||||||
|
() => videoRef.current?.seekTo || (() => {}),
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const getAudioTracks = useMemo(
|
||||||
|
() => videoRef.current?.getAudioTracks,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const getSubtitleTracks = useMemo(
|
||||||
|
() => videoRef.current?.getSubtitleTracks,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const setSubtitleTrack = useMemo(
|
||||||
|
() => videoRef.current?.setSubtitleTrack,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const setSubtitleURL = useMemo(
|
||||||
|
() => videoRef.current?.setSubtitleURL,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const setAudioTrack = useMemo(
|
||||||
|
() => videoRef.current?.setAudioTrack,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const setVideoAspectRatio = useMemo(
|
||||||
|
() => videoRef.current?.setVideoAspectRatio,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const setVideoScaleFactor = useMemo(
|
||||||
|
() => videoRef.current?.setVideoScaleFactor,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Debug: component render"); // Uncomment to debug re-renders
|
||||||
|
|
||||||
// Show error UI first, before checking loading/missing‐data
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
return (
|
return (
|
||||||
@@ -567,7 +655,7 @@ export default function page() {
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "blue",
|
backgroundColor: "black",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
@@ -624,19 +712,19 @@ export default function page() {
|
|||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
setShowControls={setShowControls}
|
setShowControls={setShowControls}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
startPictureInPicture={videoRef.current?.startPictureInPicture}
|
startPictureInPicture={startPictureInPicture}
|
||||||
play={videoRef.current?.play || (() => {})}
|
play={play}
|
||||||
pause={videoRef.current?.pause || (() => {})}
|
pause={pause}
|
||||||
seek={videoRef.current?.seekTo || (() => {})}
|
seek={seek}
|
||||||
enableTrickplay={true}
|
enableTrickplay={true}
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
getSubtitleTracks={getSubtitleTracks}
|
||||||
offline={offline}
|
offline={offline}
|
||||||
setSubtitleTrack={videoRef.current?.setSubtitleTrack}
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
setSubtitleURL={videoRef.current?.setSubtitleURL}
|
setSubtitleURL={setSubtitleURL}
|
||||||
setAudioTrack={videoRef.current?.setAudioTrack}
|
setAudioTrack={setAudioTrack}
|
||||||
setVideoAspectRatio={videoRef.current?.setVideoAspectRatio}
|
setVideoAspectRatio={setVideoAspectRatio}
|
||||||
setVideoScaleFactor={videoRef.current?.setVideoScaleFactor}
|
setVideoScaleFactor={setVideoScaleFactor}
|
||||||
aspectRatio={aspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
scaleFactor={scaleFactor}
|
scaleFactor={scaleFactor}
|
||||||
setAspectRatio={setAspectRatio}
|
setAspectRatio={setAspectRatio}
|
||||||
|
|||||||
@@ -106,20 +106,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
// Initialize selectedOptions with default values
|
// Initialize selectedOptions with default values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (itemsNotDownloaded.length === 1) {
|
setSelectedOptions(() => ({
|
||||||
setSelectedOptions(() => ({
|
bitrate: defaultBitrate,
|
||||||
bitrate: defaultBitrate,
|
mediaSource: defaultMediaSource,
|
||||||
mediaSource: defaultMediaSource,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
audioIndex: defaultAudioIndex,
|
||||||
audioIndex: defaultAudioIndex,
|
}));
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
itemsNotDownloaded.length,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const itemsToDownload = useMemo(() => {
|
const itemsToDownload = useMemo(() => {
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-4' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
|
|||||||
@@ -24,13 +24,11 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import Animated, {
|
import {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
useAnimatedReaction,
|
useAnimatedReaction,
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -163,45 +161,11 @@ export const Controls: FC<Props> = ({
|
|||||||
const min = useSharedValue(0);
|
const min = useSharedValue(0);
|
||||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||||
|
|
||||||
// Animated opacity for smooth transitions
|
|
||||||
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
|
|
||||||
|
|
||||||
// Animated scale for slider
|
|
||||||
const sliderScale = useSharedValue(1);
|
|
||||||
|
|
||||||
const wasPlayingRef = useRef(false);
|
const wasPlayingRef = useRef(false);
|
||||||
const lastProgressRef = useRef<number>(0);
|
const lastProgressRef = useRef<number>(0);
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
// Animate controls opacity when showControls changes
|
|
||||||
useEffect(() => {
|
|
||||||
controlsOpacity.value = withTiming(showControls ? 1 : 0, {
|
|
||||||
duration: 300,
|
|
||||||
});
|
|
||||||
}, [showControls, controlsOpacity]);
|
|
||||||
|
|
||||||
// Animated styles for controls
|
|
||||||
const animatedControlsStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: controlsOpacity.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Animated style for black overlay (75% opacity when visible)
|
|
||||||
const animatedOverlayStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: controlsOpacity.value * 0.75,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Animated style for slider scale
|
|
||||||
const animatedSliderStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [{ scaleY: sliderScale.value }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
prefetchAllTrickplayImages();
|
prefetchAllTrickplayImages();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -318,18 +282,31 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
const effectiveProgress = useSharedValue(0);
|
const effectiveProgress = useSharedValue(0);
|
||||||
|
|
||||||
// Recompute progress whenever remote scrubbing is active
|
// Recompute progress whenever remote scrubbing is active or when progress significantly changes
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => ({
|
() => ({
|
||||||
isScrubbing: isRemoteScrubbing.value,
|
isScrubbing: isRemoteScrubbing.value,
|
||||||
scrub: remoteScrubProgress.value,
|
scrub: remoteScrubProgress.value,
|
||||||
actual: progress.value,
|
actual: progress.value,
|
||||||
}),
|
}),
|
||||||
(current) => {
|
(current, previous) => {
|
||||||
effectiveProgress.value =
|
// Always update if scrubbing state changed or we're currently scrubbing
|
||||||
current.isScrubbing && current.scrub != null
|
if (
|
||||||
? current.scrub
|
current.isScrubbing !== previous?.isScrubbing ||
|
||||||
: current.actual;
|
current.isScrubbing
|
||||||
|
) {
|
||||||
|
effectiveProgress.value =
|
||||||
|
current.isScrubbing && current.scrub != null
|
||||||
|
? current.scrub
|
||||||
|
: current.actual;
|
||||||
|
} else {
|
||||||
|
// When not scrubbing, only update if progress changed significantly (1 second)
|
||||||
|
const progressUnit = isVlc ? 1000 : 10000000; // 1 second in ms or ticks
|
||||||
|
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
|
||||||
|
if (progressDiff >= progressUnit) {
|
||||||
|
effectiveProgress.value = current.actual;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -486,6 +463,9 @@ export const Controls: FC<Props> = ({
|
|||||||
[goToNextItem],
|
[goToNextItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const lastCurrentTimeRef = useRef(0);
|
||||||
|
const lastRemainingTimeRef = useRef(0);
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
const updateTimes = useCallback(
|
||||||
(currentProgress: number, maxValue: number) => {
|
(currentProgress: number, maxValue: number) => {
|
||||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
||||||
@@ -493,8 +473,25 @@ export const Controls: FC<Props> = ({
|
|||||||
? maxValue - currentProgress
|
? maxValue - currentProgress
|
||||||
: ticksToSeconds(maxValue - currentProgress);
|
: ticksToSeconds(maxValue - currentProgress);
|
||||||
|
|
||||||
setCurrentTime(current);
|
// Only update state if the displayed time actually changed (avoid sub-second updates)
|
||||||
setRemainingTime(remaining);
|
const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1));
|
||||||
|
const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1));
|
||||||
|
const lastCurrentSeconds = Math.floor(
|
||||||
|
lastCurrentTimeRef.current / (isVlc ? 1000 : 1),
|
||||||
|
);
|
||||||
|
const lastRemainingSeconds = Math.floor(
|
||||||
|
lastRemainingTimeRef.current / (isVlc ? 1000 : 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentSeconds !== lastCurrentSeconds ||
|
||||||
|
remainingSeconds !== lastRemainingSeconds
|
||||||
|
) {
|
||||||
|
setCurrentTime(current);
|
||||||
|
setRemainingTime(remaining);
|
||||||
|
lastCurrentTimeRef.current = current;
|
||||||
|
lastRemainingTimeRef.current = remaining;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[goToNextItem, isVlc],
|
[goToNextItem, isVlc],
|
||||||
);
|
);
|
||||||
@@ -552,31 +549,19 @@ export const Controls: FC<Props> = ({
|
|||||||
if (!showControls) {
|
if (!showControls) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scale up the slider immediately on touch
|
|
||||||
sliderScale.value = withTiming(1.4, { duration: 300 });
|
|
||||||
}, [showControls]);
|
}, [showControls]);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback(() => {
|
const handleTouchEnd = useCallback(() => {
|
||||||
if (!showControls) {
|
if (!showControls) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scale down the slider on touch end (only if not sliding, to avoid conflict with onSlidingComplete)
|
|
||||||
if (!isSliding) {
|
|
||||||
sliderScale.value = withTiming(1.0, { duration: 300 });
|
|
||||||
}
|
|
||||||
}, [showControls, isSliding]);
|
}, [showControls, isSliding]);
|
||||||
|
|
||||||
const handleSliderComplete = useCallback(
|
const handleSliderComplete = useCallback(
|
||||||
async (value: number) => {
|
async (value: number) => {
|
||||||
|
setIsSliding(false);
|
||||||
isSeeking.value = false;
|
isSeeking.value = false;
|
||||||
progress.value = value;
|
progress.value = value;
|
||||||
setIsSliding(false);
|
|
||||||
|
|
||||||
// Scale down the slider
|
|
||||||
sliderScale.value = withTiming(1.0, { duration: 200 });
|
|
||||||
|
|
||||||
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
|
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
play();
|
play();
|
||||||
@@ -791,10 +776,10 @@ export const Controls: FC<Props> = ({
|
|||||||
<VideoTouchOverlay
|
<VideoTouchOverlay
|
||||||
screenWidth={screenWidth}
|
screenWidth={screenWidth}
|
||||||
screenHeight={screenHeight}
|
screenHeight={screenHeight}
|
||||||
|
showControls={showControls}
|
||||||
onToggleControls={toggleControls}
|
onToggleControls={toggleControls}
|
||||||
animatedStyle={animatedOverlayStyle}
|
|
||||||
/>
|
/>
|
||||||
<Animated.View
|
<View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -803,8 +788,8 @@ export const Controls: FC<Props> = ({
|
|||||||
width: settings?.safeAreaInControlsEnabled
|
width: settings?.safeAreaInControlsEnabled
|
||||||
? screenWidth - insets.left - insets.right
|
? screenWidth - insets.left - insets.right
|
||||||
: screenWidth,
|
: screenWidth,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
},
|
},
|
||||||
animatedControlsStyle,
|
|
||||||
]}
|
]}
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={"flex flex-row w-full pt-2"}
|
className={"flex flex-row w-full pt-2"}
|
||||||
@@ -883,39 +868,33 @@ export const Controls: FC<Props> = ({
|
|||||||
<Ionicons name='close' size={24} color='white' />
|
<Ionicons name='close' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
|
<View
|
||||||
<Animated.View
|
style={{
|
||||||
style={[
|
position: "absolute",
|
||||||
{
|
top: "50%", // Center vertically
|
||||||
position: "absolute",
|
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
||||||
top: "50%", // Center vertically
|
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
||||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
flexDirection: "row",
|
||||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
justifyContent: "space-between",
|
||||||
flexDirection: "row",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
|
||||||
alignItems: "center",
|
paddingHorizontal: "28%", // Add some padding to the left and right
|
||||||
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
|
}}
|
||||||
paddingHorizontal: 17,
|
|
||||||
},
|
|
||||||
animatedControlsStyle,
|
|
||||||
]}
|
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
>
|
>
|
||||||
{/* Brightness Control */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
position: "absolute",
|
||||||
height: 50,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
|
||||||
transform: [{ rotate: "270deg" }],
|
left: 0,
|
||||||
|
bottom: 30,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BrightnessSlider />
|
<BrightnessSlider />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Skip Backward */}
|
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<TouchableOpacity onPress={handleSkipBackward}>
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
<View
|
<View
|
||||||
@@ -923,6 +902,7 @@ export const Controls: FC<Props> = ({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@@ -948,8 +928,9 @@ export const Controls: FC<Props> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Play/Pause Button */}
|
<View
|
||||||
<View style={{ alignItems: "center" }}>
|
style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}
|
||||||
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
togglePlay();
|
togglePlay();
|
||||||
@@ -960,6 +941,9 @@ export const Controls: FC<Props> = ({
|
|||||||
name={isPlaying ? "pause" : "play"}
|
name={isPlaying ? "pause" : "play"}
|
||||||
size={50}
|
size={50}
|
||||||
color='white'
|
color='white'
|
||||||
|
style={{
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Loader size={"large"} />
|
<Loader size={"large"} />
|
||||||
@@ -967,7 +951,6 @@ export const Controls: FC<Props> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Skip Forward */}
|
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<TouchableOpacity onPress={handleSkipForward}>
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
<View
|
<View
|
||||||
@@ -975,6 +958,7 @@ export const Controls: FC<Props> = ({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name='refresh-outline' size={50} color='white' />
|
<Ionicons name='refresh-outline' size={50} color='white' />
|
||||||
@@ -992,23 +976,21 @@ export const Controls: FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Volume/Audio Control */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
position: "absolute",
|
||||||
height: 50,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
|
||||||
transform: [{ rotate: "270deg" }],
|
bottom: 30,
|
||||||
|
right: 0,
|
||||||
opacity: showAudioSlider || showControls ? 1 : 0,
|
opacity: showAudioSlider || showControls ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AudioSlider setVisibility={setShowAudioSlider} />
|
<AudioSlider setVisibility={setShowAudioSlider} />
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
|
|
||||||
<Animated.View
|
<View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -1018,7 +1000,6 @@ export const Controls: FC<Props> = ({
|
|||||||
? Math.max(insets.bottom - 17, 0)
|
? Math.max(insets.bottom - 17, 0)
|
||||||
: 0,
|
: 0,
|
||||||
},
|
},
|
||||||
animatedControlsStyle,
|
|
||||||
]}
|
]}
|
||||||
className={"flex flex-col px-2"}
|
className={"flex flex-col px-2"}
|
||||||
onTouchStart={handleControlsInteraction}
|
onTouchStart={handleControlsInteraction}
|
||||||
@@ -1034,6 +1015,7 @@ export const Controls: FC<Props> = ({
|
|||||||
style={{
|
style={{
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignSelf: "flex-end", // Shrink height based on content
|
alignSelf: "flex-end", // Shrink height based on content
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
>
|
>
|
||||||
@@ -1082,6 +1064,9 @@ export const Controls: FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
className={"flex flex-col-reverse rounded-lg items-center my-2"}
|
className={"flex flex-col-reverse rounded-lg items-center my-2"}
|
||||||
|
style={{
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
>
|
>
|
||||||
<View className={"flex flex-col w-full shrink"}>
|
<View className={"flex flex-col w-full shrink"}>
|
||||||
@@ -1094,35 +1079,32 @@ export const Controls: FC<Props> = ({
|
|||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
<Animated.View style={animatedSliderStyle}>
|
<Slider
|
||||||
<Slider
|
theme={{
|
||||||
theme={{
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
minimumTrackTintColor: "#fff",
|
||||||
minimumTrackTintColor: "#fff",
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
bubbleBackgroundColor: "#fff",
|
||||||
bubbleBackgroundColor: "#fff",
|
bubbleTextColor: "#666",
|
||||||
bubbleTextColor: "#666",
|
heartbeatColor: "#999",
|
||||||
heartbeatColor: "#999",
|
}}
|
||||||
}}
|
renderThumb={() => null}
|
||||||
renderThumb={() => null}
|
cache={cacheProgress}
|
||||||
cache={cacheProgress}
|
onSlidingStart={handleSliderStart}
|
||||||
onSlidingStart={handleSliderStart}
|
onSlidingComplete={handleSliderComplete}
|
||||||
onSlidingComplete={handleSliderComplete}
|
onValueChange={handleSliderChange}
|
||||||
onValueChange={handleSliderChange}
|
containerStyle={{
|
||||||
containerStyle={{
|
borderRadius: 100,
|
||||||
borderRadius: 100,
|
}}
|
||||||
}}
|
renderBubble={() =>
|
||||||
renderBubble={() =>
|
(isSliding || showRemoteBubble) && memoizedRenderBubble()
|
||||||
(isSliding || showRemoteBubble) &&
|
}
|
||||||
memoizedRenderBubble()
|
sliderHeight={10}
|
||||||
}
|
thumbWidth={0}
|
||||||
sliderHeight={10}
|
progress={effectiveProgress}
|
||||||
thumbWidth={0}
|
minimumValue={min}
|
||||||
progress={effectiveProgress}
|
maximumValue={max}
|
||||||
minimumValue={min}
|
/>
|
||||||
maximumValue={max}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center justify-between mt-2'>
|
<View className='flex flex-row items-center justify-between mt-2'>
|
||||||
<Text className='text-[12px] text-neutral-400'>
|
<Text className='text-[12px] text-neutral-400'>
|
||||||
@@ -1152,7 +1134,7 @@ export const Controls: FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||||
|
|||||||
@@ -1,43 +1,38 @@
|
|||||||
import { Pressable } from "react-native";
|
import { Pressable } from "react-native";
|
||||||
import Animated, { type AnimatedStyle } from "react-native-reanimated";
|
|
||||||
import { useTapDetection } from "./useTapDetection";
|
import { useTapDetection } from "./useTapDetection";
|
||||||
|
|
||||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
screenWidth: number;
|
screenWidth: number;
|
||||||
screenHeight: number;
|
screenHeight: number;
|
||||||
|
showControls: boolean;
|
||||||
onToggleControls: () => void;
|
onToggleControls: () => void;
|
||||||
animatedStyle: AnimatedStyle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VideoTouchOverlay = ({
|
export const VideoTouchOverlay = ({
|
||||||
screenWidth,
|
screenWidth,
|
||||||
screenHeight,
|
screenHeight,
|
||||||
|
showControls,
|
||||||
onToggleControls,
|
onToggleControls,
|
||||||
animatedStyle,
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { handleTouchStart, handleTouchEnd } = useTapDetection({
|
const { handleTouchStart, handleTouchEnd } = useTapDetection({
|
||||||
onValidTap: onToggleControls,
|
onValidTap: onToggleControls,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedPressable
|
<Pressable
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
style={[
|
style={{
|
||||||
{
|
position: "absolute",
|
||||||
position: "absolute",
|
width: screenWidth,
|
||||||
width: screenWidth,
|
height: screenHeight,
|
||||||
height: screenHeight,
|
backgroundColor: "black",
|
||||||
backgroundColor: "black",
|
left: 0,
|
||||||
left: 0,
|
right: 0,
|
||||||
right: 0,
|
top: 0,
|
||||||
top: 0,
|
bottom: 0,
|
||||||
bottom: 0,
|
opacity: showControls ? 0.75 : 0,
|
||||||
},
|
}}
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
6
eas.json
6
eas.json
@@ -46,14 +46,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.31.0",
|
"channel": "0.32.1",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.31.0",
|
"channel": "0.32.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.31.0",
|
"channel": "0.32.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const usePlaybackManager = ({
|
|||||||
useDownload();
|
useDownload();
|
||||||
|
|
||||||
/** Whether the device is online. actually it's connected to the internet. */
|
/** Whether the device is online. actually it's connected to the internet. */
|
||||||
const isOnline = netInfo.isConnected;
|
const isOnline = useMemo(() => netInfo.isConnected, [netInfo.isConnected]);
|
||||||
|
|
||||||
// Adjacent episodes logic
|
// Adjacent episodes logic
|
||||||
const { data: adjacentItems } = useQuery({
|
const { data: adjacentItems } = useQuery({
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.31.0" },
|
clientInfo: { name: "Streamyfin", version: "0.32.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -93,7 +93,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.31.0"`,
|
}, DeviceId="${deviceId}", Version="0.32.1"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user