mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
4 Commits
renovate/r
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cab50750f | ||
|
|
d795e82581 | ||
|
|
e7161bc9ab | ||
|
|
8e74363f32 |
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.32.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": 61,
|
"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}
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -69,7 +69,7 @@
|
|||||||
"react-native-pager-view": "^6.9.1",
|
"react-native-pager-view": "^6.9.1",
|
||||||
"react-native-reanimated": "~3.16.7",
|
"react-native-reanimated": "~3.16.7",
|
||||||
"react-native-reanimated-carousel": "4.0.2",
|
"react-native-reanimated-carousel": "4.0.2",
|
||||||
"react-native-safe-area-context": "5.6.1",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
"react-native-screens": "~4.11.1",
|
"react-native-screens": "~4.11.1",
|
||||||
"react-native-svg": "15.11.2",
|
"react-native-svg": "15.11.2",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
@@ -1646,7 +1646,7 @@
|
|||||||
|
|
||||||
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-vNpCfPlFoOVKHd+oB7B0luoJswp+nyz0NdJD8+LCrf25JiNQXfM22RSJhLaksBHqk3fm8R4fKWPNcfy5w7wL1Q=="],
|
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-vNpCfPlFoOVKHd+oB7B0luoJswp+nyz0NdJD8+LCrf25JiNQXfM22RSJhLaksBHqk3fm8R4fKWPNcfy5w7wL1Q=="],
|
||||||
|
|
||||||
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/wJE58HLEAkATzhhX1xSr+fostLsK8Q97EfpfMDKo8jlOc1QKESSX/FQrhk7HhQH/2uSaox4Y86sNaI02kteiA=="],
|
"react-native-safe-area-context": ["react-native-safe-area-context@5.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA=="],
|
||||||
|
|
||||||
"react-native-screens": ["react-native-screens@4.11.1", "", { "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.1.7", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw=="],
|
"react-native-screens": ["react-native-screens@4.11.1", "", { "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.1.7", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw=="],
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import Animated, { type SharedValue } from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { formatTimeString } from "@/utils/time";
|
|
||||||
import { SLIDER_CONFIG, SLIDER_THEME } from "../constants";
|
|
||||||
import NextEpisodeCountDownButton from "../NextEpisodeCountDownButton";
|
|
||||||
import SkipButton from "../SkipButton";
|
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
|
||||||
|
|
||||||
interface BottomControlsProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
showControls: boolean;
|
|
||||||
isSliding: boolean;
|
|
||||||
showRemoteBubble: boolean;
|
|
||||||
currentTime: number;
|
|
||||||
remainingTime: number;
|
|
||||||
isVlc: boolean;
|
|
||||||
nextItem?: BaseItemDto;
|
|
||||||
showSkipButton: boolean;
|
|
||||||
showSkipCreditButton: boolean;
|
|
||||||
cacheProgress: SharedValue<number>;
|
|
||||||
min: SharedValue<number>;
|
|
||||||
max: SharedValue<number>;
|
|
||||||
effectiveProgress: SharedValue<number>;
|
|
||||||
animatedControlsStyle: any;
|
|
||||||
animatedSliderStyle: any;
|
|
||||||
trickPlayUrl?: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
trickplayInfo?: {
|
|
||||||
aspectRatio: number;
|
|
||||||
data: {
|
|
||||||
TileWidth?: number;
|
|
||||||
TileHeight?: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
time: {
|
|
||||||
hours: number;
|
|
||||||
minutes: number;
|
|
||||||
seconds: number;
|
|
||||||
};
|
|
||||||
getEndTime: () => string;
|
|
||||||
onControlsInteraction: () => void;
|
|
||||||
onTouchStart: () => void;
|
|
||||||
onTouchEnd: () => void;
|
|
||||||
onSliderStart: () => void;
|
|
||||||
onSliderComplete: (value: number) => void;
|
|
||||||
onSliderChange: (value: number) => void;
|
|
||||||
onSkipIntro: () => void;
|
|
||||||
onSkipCredit: () => void;
|
|
||||||
onNextEpisodeAutoPlay: () => void;
|
|
||||||
onNextEpisodeManual: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BottomControls: React.FC<BottomControlsProps> = ({
|
|
||||||
item,
|
|
||||||
showControls,
|
|
||||||
isSliding,
|
|
||||||
showRemoteBubble,
|
|
||||||
currentTime,
|
|
||||||
remainingTime,
|
|
||||||
isVlc,
|
|
||||||
nextItem,
|
|
||||||
showSkipButton,
|
|
||||||
showSkipCreditButton,
|
|
||||||
cacheProgress,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
effectiveProgress,
|
|
||||||
animatedControlsStyle,
|
|
||||||
animatedSliderStyle,
|
|
||||||
trickPlayUrl,
|
|
||||||
trickplayInfo,
|
|
||||||
time,
|
|
||||||
getEndTime,
|
|
||||||
onControlsInteraction,
|
|
||||||
onTouchStart,
|
|
||||||
onTouchEnd,
|
|
||||||
onSliderStart,
|
|
||||||
onSliderComplete,
|
|
||||||
onSliderChange,
|
|
||||||
onSkipIntro,
|
|
||||||
onSkipCredit,
|
|
||||||
onNextEpisodeAutoPlay,
|
|
||||||
onNextEpisodeManual,
|
|
||||||
}) => {
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const renderTrickplayBubble = () => (
|
|
||||||
<TrickplayBubble
|
|
||||||
trickPlayUrl={trickPlayUrl}
|
|
||||||
trickplayInfo={trickplayInfo}
|
|
||||||
time={time}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
|
||||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
|
||||||
bottom: settings?.safeAreaInControlsEnabled
|
|
||||||
? Math.max(insets.bottom - 17, 0)
|
|
||||||
: 0,
|
|
||||||
},
|
|
||||||
animatedControlsStyle,
|
|
||||||
]}
|
|
||||||
className={"flex flex-col px-2"}
|
|
||||||
onTouchStart={onControlsInteraction}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='shrink flex flex-col justify-center h-full'
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "column",
|
|
||||||
alignSelf: "flex-end", // Shrink height based on content
|
|
||||||
}}
|
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
|
||||||
>
|
|
||||||
{item?.Type === "Episode" && (
|
|
||||||
<Text className='opacity-50'>
|
|
||||||
{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text className='font-bold text-xl'>{item?.Name}</Text>
|
|
||||||
{item?.Type === "Movie" && (
|
|
||||||
<Text className='text-xs opacity-50'>{item?.ProductionYear}</Text>
|
|
||||||
)}
|
|
||||||
{item?.Type === "Audio" && (
|
|
||||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View className='flex flex-row space-x-2'>
|
|
||||||
<SkipButton
|
|
||||||
showButton={showSkipButton}
|
|
||||||
onPress={onSkipIntro}
|
|
||||||
buttonText='Skip Intro'
|
|
||||||
/>
|
|
||||||
<SkipButton
|
|
||||||
showButton={showSkipCreditButton}
|
|
||||||
onPress={onSkipCredit}
|
|
||||||
buttonText='Skip Credits'
|
|
||||||
/>
|
|
||||||
{(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
|
||||||
settings.autoPlayEpisodeCount <
|
|
||||||
settings.maxAutoPlayEpisodeCount.value) && (
|
|
||||||
<NextEpisodeCountDownButton
|
|
||||||
show={
|
|
||||||
!nextItem
|
|
||||||
? false
|
|
||||||
: isVlc
|
|
||||||
? remainingTime < 10000
|
|
||||||
: remainingTime < 10
|
|
||||||
}
|
|
||||||
onFinish={onNextEpisodeAutoPlay}
|
|
||||||
onPress={onNextEpisodeManual}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={"flex flex-col-reverse rounded-lg items-center my-2"}
|
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
|
||||||
>
|
|
||||||
<View className={"flex flex-col w-full shrink"}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: SLIDER_CONFIG.HEIGHT,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "stretch",
|
|
||||||
}}
|
|
||||||
onTouchStart={onTouchStart}
|
|
||||||
onTouchEnd={onTouchEnd}
|
|
||||||
>
|
|
||||||
<Animated.View style={animatedSliderStyle}>
|
|
||||||
<Slider
|
|
||||||
theme={SLIDER_THEME}
|
|
||||||
renderThumb={() => null}
|
|
||||||
cache={cacheProgress}
|
|
||||||
onSlidingStart={onSliderStart}
|
|
||||||
onSlidingComplete={onSliderComplete}
|
|
||||||
onValueChange={onSliderChange}
|
|
||||||
containerStyle={{
|
|
||||||
borderRadius: SLIDER_CONFIG.BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
renderBubble={() =>
|
|
||||||
(isSliding || showRemoteBubble) && renderTrickplayBubble()
|
|
||||||
}
|
|
||||||
sliderHeight={SLIDER_CONFIG.HEIGHT}
|
|
||||||
thumbWidth={SLIDER_CONFIG.THUMB_WIDTH}
|
|
||||||
progress={effectiveProgress}
|
|
||||||
minimumValue={min}
|
|
||||||
maximumValue={max}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
<View className='flex flex-row items-center justify-between mt-2'>
|
|
||||||
<Text className='text-[12px] text-neutral-400'>
|
|
||||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
|
||||||
</Text>
|
|
||||||
<View className='flex flex-col items-end'>
|
|
||||||
<Text className='text-[12px] text-neutral-400'>
|
|
||||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
|
||||||
ends at {getEndTime()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import React from "react";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import Animated from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import AudioSlider from "../AudioSlider";
|
|
||||||
import BrightnessSlider from "../BrightnessSlider";
|
|
||||||
|
|
||||||
interface CenterControlsProps {
|
|
||||||
showControls: boolean;
|
|
||||||
showAudioSlider: boolean;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isBuffering: boolean;
|
|
||||||
rewindSkipTime?: number;
|
|
||||||
forwardSkipTime?: number;
|
|
||||||
animatedControlsStyle: any;
|
|
||||||
setShowAudioSlider: (show: boolean) => void;
|
|
||||||
onTogglePlay: () => void;
|
|
||||||
onSkipBackward: () => void;
|
|
||||||
onSkipForward: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CenterControls: React.FC<CenterControlsProps> = ({
|
|
||||||
showControls,
|
|
||||||
showAudioSlider,
|
|
||||||
isPlaying,
|
|
||||||
isBuffering,
|
|
||||||
rewindSkipTime,
|
|
||||||
forwardSkipTime,
|
|
||||||
animatedControlsStyle,
|
|
||||||
setShowAudioSlider,
|
|
||||||
onTogglePlay,
|
|
||||||
onSkipBackward,
|
|
||||||
onSkipForward,
|
|
||||||
}) => {
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%", // Center vertically
|
|
||||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
|
||||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
|
|
||||||
paddingHorizontal: 17,
|
|
||||||
},
|
|
||||||
animatedControlsStyle,
|
|
||||||
]}
|
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
|
||||||
>
|
|
||||||
{/* Brightness Control */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
transform: [{ rotate: "270deg" }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BrightnessSlider />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Skip Backward */}
|
|
||||||
{!Platform.isTV && (
|
|
||||||
<TouchableOpacity onPress={onSkipBackward}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='refresh-outline'
|
|
||||||
size={50}
|
|
||||||
color='white'
|
|
||||||
style={{
|
|
||||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
color: "white",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "bold",
|
|
||||||
bottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rewindSkipTime}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Play/Pause Button */}
|
|
||||||
<View style={{ alignItems: "center" }}>
|
|
||||||
<TouchableOpacity onPress={onTogglePlay}>
|
|
||||||
{!isBuffering ? (
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={50}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Loader size={"large"} />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Skip Forward */}
|
|
||||||
{!Platform.isTV && (
|
|
||||||
<TouchableOpacity onPress={onSkipForward}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='refresh-outline' size={50} color='white' />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
color: "white",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "bold",
|
|
||||||
bottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{forwardSkipTime}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Volume/Audio Control */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
transform: [{ rotate: "270deg" }],
|
|
||||||
opacity: showAudioSlider || showControls ? 1 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AudioSlider setVisibility={setShowAudioSlider} />
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import React from "react";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import Animated from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
|
||||||
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
|
||||||
import { VideoProvider } from "../contexts/VideoContext";
|
|
||||||
import DropdownView from "../dropdown/DropdownView";
|
|
||||||
import { type ScaleFactor, ScaleFactorSelector } from "../ScaleFactorSelector";
|
|
||||||
import {
|
|
||||||
type AspectRatio,
|
|
||||||
AspectRatioSelector,
|
|
||||||
} from "../VideoScalingModeSelector";
|
|
||||||
|
|
||||||
interface TopControlsBarProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSource?: MediaSourceInfo | null;
|
|
||||||
offline: boolean;
|
|
||||||
showControls: boolean;
|
|
||||||
aspectRatio: AspectRatio;
|
|
||||||
scaleFactor: ScaleFactor;
|
|
||||||
previousItem?: BaseItemDto;
|
|
||||||
nextItem?: BaseItemDto;
|
|
||||||
animatedControlsStyle: any;
|
|
||||||
screenWidth: number;
|
|
||||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
|
||||||
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
|
||||||
setSubtitleURL?: (url: string, customName: string) => void;
|
|
||||||
setSubtitleTrack?: (index: number) => void;
|
|
||||||
setAudioTrack?: (index: number) => void;
|
|
||||||
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
|
||||||
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
|
||||||
startPictureInPicture?: () => Promise<void>;
|
|
||||||
onAspectRatioChange: (ratio: AspectRatio) => void;
|
|
||||||
onScaleFactorChange: (scale: ScaleFactor) => void;
|
|
||||||
onEpisodeModeToggle: () => void;
|
|
||||||
onGoToPreviousItem: () => void;
|
|
||||||
onGoToNextItem: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TopControlsBar: React.FC<TopControlsBarProps> = ({
|
|
||||||
item,
|
|
||||||
mediaSource,
|
|
||||||
offline,
|
|
||||||
showControls,
|
|
||||||
aspectRatio,
|
|
||||||
scaleFactor,
|
|
||||||
previousItem,
|
|
||||||
nextItem,
|
|
||||||
animatedControlsStyle,
|
|
||||||
screenWidth,
|
|
||||||
getAudioTracks,
|
|
||||||
getSubtitleTracks,
|
|
||||||
setSubtitleURL,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setAudioTrack,
|
|
||||||
setVideoAspectRatio,
|
|
||||||
setVideoScaleFactor,
|
|
||||||
startPictureInPicture,
|
|
||||||
onAspectRatioChange,
|
|
||||||
onScaleFactorChange,
|
|
||||||
onEpisodeModeToggle,
|
|
||||||
onGoToPreviousItem,
|
|
||||||
onGoToNextItem,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
|
||||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
|
||||||
width: settings?.safeAreaInControlsEnabled
|
|
||||||
? screenWidth - insets.left - insets.right
|
|
||||||
: screenWidth,
|
|
||||||
},
|
|
||||||
animatedControlsStyle,
|
|
||||||
]}
|
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
|
||||||
className={"flex flex-row w-full pt-2"}
|
|
||||||
>
|
|
||||||
<View className='mr-auto'>
|
|
||||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
|
||||||
<VideoProvider
|
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setAudioTrack={setAudioTrack}
|
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
|
||||||
setSubtitleURL={setSubtitleURL}
|
|
||||||
>
|
|
||||||
<DropdownView />
|
|
||||||
</VideoProvider>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='flex flex-row items-center space-x-2 '>
|
|
||||||
{!Platform.isTV &&
|
|
||||||
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
|
|
||||||
Platform.OS === "android") && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={startPictureInPicture}
|
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name='picture-in-picture'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
style={{ opacity: showControls ? 1 : 0 }}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{item?.Type === "Episode" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onEpisodeModeToggle}
|
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
|
||||||
>
|
|
||||||
<Ionicons name='list' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{previousItem && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onGoToPreviousItem}
|
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
|
||||||
>
|
|
||||||
<Ionicons name='play-skip-back' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{nextItem && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onGoToNextItem}
|
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
|
||||||
>
|
|
||||||
<Ionicons name='play-skip-forward' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{/* Video Controls */}
|
|
||||||
<AspectRatioSelector
|
|
||||||
currentRatio={aspectRatio}
|
|
||||||
onRatioChange={onAspectRatioChange}
|
|
||||||
disabled={!setVideoAspectRatio}
|
|
||||||
/>
|
|
||||||
<ScaleFactorSelector
|
|
||||||
currentScale={scaleFactor}
|
|
||||||
onScaleChange={onScaleFactorChange}
|
|
||||||
disabled={!setVideoScaleFactor}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onClose}
|
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
|
||||||
>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { Image } from "expo-image";
|
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import {
|
|
||||||
calculateTrickplayDimensions,
|
|
||||||
formatTimeForBubble,
|
|
||||||
} from "../utils/trickplayUtils";
|
|
||||||
|
|
||||||
interface TrickplayBubbleProps {
|
|
||||||
trickPlayUrl?: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
trickplayInfo?: {
|
|
||||||
aspectRatio: number;
|
|
||||||
data: {
|
|
||||||
TileWidth?: number;
|
|
||||||
TileHeight?: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
time: {
|
|
||||||
hours: number;
|
|
||||||
minutes: number;
|
|
||||||
seconds: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TrickplayBubble: React.FC<TrickplayBubbleProps> = ({
|
|
||||||
trickPlayUrl,
|
|
||||||
trickplayInfo,
|
|
||||||
time,
|
|
||||||
}) => {
|
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { x, y, url } = trickPlayUrl;
|
|
||||||
const { tileWidth, tileHeight, scaledWidth } = calculateTrickplayDimensions(
|
|
||||||
trickplayInfo.aspectRatio,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: -62,
|
|
||||||
bottom: 0,
|
|
||||||
paddingTop: 30,
|
|
||||||
paddingBottom: 5,
|
|
||||||
width: scaledWidth,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: tileWidth,
|
|
||||||
height: tileHeight,
|
|
||||||
alignSelf: "center",
|
|
||||||
transform: [{ scale: 1.4 }],
|
|
||||||
borderRadius: 5,
|
|
||||||
}}
|
|
||||||
className='bg-neutral-800 overflow-hidden'
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
style={{
|
|
||||||
width: 150 * (trickplayInfo.data.TileWidth || 1),
|
|
||||||
height:
|
|
||||||
(150 / trickplayInfo.aspectRatio) *
|
|
||||||
(trickplayInfo.data.TileHeight || 1),
|
|
||||||
transform: [
|
|
||||||
{ translateX: -x * tileWidth },
|
|
||||||
{ translateY: -y * tileHeight },
|
|
||||||
],
|
|
||||||
resizeMode: "cover",
|
|
||||||
}}
|
|
||||||
source={{ uri: url }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
marginTop: 30,
|
|
||||||
fontSize: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTimeForBubble(time)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
export const CONTROLS_TIMEOUT = 4000;
|
|
||||||
|
|
||||||
export const TRICKPLAY_TILE_WIDTH = 150;
|
|
||||||
export const TRICKPLAY_TILE_SCALE = 1.4;
|
|
||||||
|
|
||||||
export const SLIDER_SCALE_UP = 1.4;
|
|
||||||
export const SLIDER_SCALE_NORMAL = 1.0;
|
|
||||||
|
|
||||||
export const ANIMATION_DURATION = {
|
|
||||||
CONTROLS_FADE: 300,
|
|
||||||
SLIDER_SCALE: 300,
|
|
||||||
SLIDER_SCALE_COMPLETE: 200,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const SLIDER_CONFIG = {
|
|
||||||
HEIGHT: 10,
|
|
||||||
THUMB_WIDTH: 0,
|
|
||||||
BORDER_RADIUS: 100,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const SLIDER_THEME = {
|
|
||||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
|
||||||
minimumTrackTintColor: "#fff",
|
|
||||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
|
||||||
bubbleBackgroundColor: "#fff",
|
|
||||||
bubbleTextColor: "#666",
|
|
||||||
heartbeatColor: "#999",
|
|
||||||
} as const;
|
|
||||||
@@ -85,6 +85,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
chosenAudioIndex?: string;
|
chosenAudioIndex?: string;
|
||||||
chosenSubtitleIndex?: string;
|
chosenSubtitleIndex?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: itemId ?? "",
|
itemId: itemId ?? "",
|
||||||
audioIndex: chosenAudioIndex,
|
audioIndex: chosenAudioIndex,
|
||||||
@@ -114,6 +115,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
mediaSource?.TranscodingUrl &&
|
mediaSource?.TranscodingUrl &&
|
||||||
!onTextBasedSubtitle;
|
!onTextBasedSubtitle;
|
||||||
|
|
||||||
|
console.log("Set player params", index, serverIndex);
|
||||||
if (shouldChangePlayerParams) {
|
if (shouldChangePlayerParams) {
|
||||||
setPlayerParams({
|
setPlayerParams({
|
||||||
chosenSubtitleIndex: serverIndex.toString(),
|
chosenSubtitleIndex: serverIndex.toString(),
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
|
|
||||||
interface UseEpisodeNavigationProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
offline: boolean;
|
|
||||||
mediaSource?: MediaSourceInfo | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useEpisodeNavigation = ({
|
|
||||||
item,
|
|
||||||
offline,
|
|
||||||
mediaSource,
|
|
||||||
}: UseEpisodeNavigationProps) => {
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
const router = useRouter();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
|
|
||||||
bitrateValue: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { previousItem, nextItem } = usePlaybackManager({
|
|
||||||
item,
|
|
||||||
isOffline: offline,
|
|
||||||
});
|
|
||||||
|
|
||||||
const goToItemCommon = useCallback(
|
|
||||||
(item: BaseItemDto) => {
|
|
||||||
if (!item || !settings) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lightHapticFeedback();
|
|
||||||
const previousIndexes = {
|
|
||||||
subtitleIndex: subtitleIndex
|
|
||||||
? Number.parseInt(subtitleIndex, 10)
|
|
||||||
: undefined,
|
|
||||||
audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
mediaSource: newMediaSource,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
|
||||||
} = getDefaultPlaySettings(
|
|
||||||
item,
|
|
||||||
settings,
|
|
||||||
previousIndexes,
|
|
||||||
mediaSource ?? undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
...(offline && { offline: "true" }),
|
|
||||||
itemId: item.Id ?? "",
|
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: newMediaSource?.Id ?? "",
|
|
||||||
bitrateValue: bitrateValue?.toString(),
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
settings,
|
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
|
||||||
mediaSource,
|
|
||||||
bitrateValue,
|
|
||||||
router,
|
|
||||||
lightHapticFeedback,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
|
||||||
if (!previousItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goToItemCommon(previousItem);
|
|
||||||
}, [previousItem, goToItemCommon]);
|
|
||||||
|
|
||||||
const goToNextItem = useCallback(
|
|
||||||
({
|
|
||||||
isAutoPlay,
|
|
||||||
resetWatchCount,
|
|
||||||
}: {
|
|
||||||
isAutoPlay?: boolean;
|
|
||||||
resetWatchCount?: boolean;
|
|
||||||
}) => {
|
|
||||||
if (!nextItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAutoPlay) {
|
|
||||||
// if we are not autoplaying, we won't update anything, we just go to the next item
|
|
||||||
goToItemCommon(nextItem);
|
|
||||||
if (resetWatchCount) {
|
|
||||||
updateSettings({
|
|
||||||
autoPlayEpisodeCount: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
|
|
||||||
if (settings.maxAutoPlayEpisodeCount.value === -1) {
|
|
||||||
goToItemCommon(nextItem);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
settings.autoPlayEpisodeCount + 1 <
|
|
||||||
settings.maxAutoPlayEpisodeCount.value
|
|
||||||
) {
|
|
||||||
goToItemCommon(nextItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
|
|
||||||
if (
|
|
||||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
|
|
||||||
) {
|
|
||||||
// update the autoPlayEpisodeCount in settings
|
|
||||||
updateSettings({
|
|
||||||
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[nextItem, goToItemCommon, settings, updateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoized handlers
|
|
||||||
const handleNextEpisodeAutoPlay = useCallback(() => {
|
|
||||||
goToNextItem({ isAutoPlay: true });
|
|
||||||
}, [goToNextItem]);
|
|
||||||
|
|
||||||
const handleNextEpisodeManual = useCallback(() => {
|
|
||||||
goToNextItem({ isAutoPlay: false });
|
|
||||||
}, [goToNextItem]);
|
|
||||||
|
|
||||||
const handleContinueWatching = useCallback(
|
|
||||||
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
|
|
||||||
goToNextItem(options);
|
|
||||||
},
|
|
||||||
[goToNextItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
previousItem,
|
|
||||||
nextItem,
|
|
||||||
goToItemCommon,
|
|
||||||
goToPreviousItem,
|
|
||||||
goToNextItem,
|
|
||||||
handleNextEpisodeAutoPlay,
|
|
||||||
handleNextEpisodeManual,
|
|
||||||
handleContinueWatching,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useTVEventHandler } from "react-native";
|
|
||||||
import type { SharedValue } from "react-native-reanimated";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { msToTicks, secondsToMs, ticksToSeconds } from "@/utils/time";
|
|
||||||
|
|
||||||
interface UseRemoteControlsProps {
|
|
||||||
progress: SharedValue<number>;
|
|
||||||
min: SharedValue<number>;
|
|
||||||
max: SharedValue<number>;
|
|
||||||
isVlc: boolean;
|
|
||||||
showControls: boolean;
|
|
||||||
isPlaying: boolean;
|
|
||||||
item: BaseItemDto;
|
|
||||||
seek: (ticks: number) => void;
|
|
||||||
play: () => void;
|
|
||||||
togglePlay: () => void;
|
|
||||||
toggleControls: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useRemoteControls = ({
|
|
||||||
progress,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
isVlc,
|
|
||||||
showControls,
|
|
||||||
isPlaying,
|
|
||||||
item,
|
|
||||||
seek,
|
|
||||||
play,
|
|
||||||
togglePlay,
|
|
||||||
toggleControls,
|
|
||||||
}: UseRemoteControlsProps) => {
|
|
||||||
const { calculateTrickplayUrl } = useTrickplay(item);
|
|
||||||
|
|
||||||
const remoteScrubProgress = useRef<SharedValue<number | null>>(null);
|
|
||||||
const isRemoteScrubbing = useRef<SharedValue<boolean>>(null);
|
|
||||||
const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10));
|
|
||||||
const [showRemoteBubble, setShowRemoteBubble] = useState(false);
|
|
||||||
const [longPressScrubMode, setLongPressScrubMode] = useState<
|
|
||||||
"FF" | "RW" | null
|
|
||||||
>(null);
|
|
||||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
|
||||||
|
|
||||||
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initialize shared values if not provided
|
|
||||||
if (!remoteScrubProgress.current) {
|
|
||||||
remoteScrubProgress.current = { value: null } as SharedValue<number | null>;
|
|
||||||
}
|
|
||||||
if (!isRemoteScrubbing.current) {
|
|
||||||
isRemoteScrubbing.current = { value: false } as SharedValue<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
useTVEventHandler((evt) => {
|
|
||||||
if (!evt) return;
|
|
||||||
|
|
||||||
switch (evt.eventType) {
|
|
||||||
case "longLeft": {
|
|
||||||
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "longRight": {
|
|
||||||
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "left":
|
|
||||||
case "right": {
|
|
||||||
isRemoteScrubbing.current!.value = true;
|
|
||||||
setShowRemoteBubble(true);
|
|
||||||
|
|
||||||
const direction = evt.eventType === "left" ? -1 : 1;
|
|
||||||
const base = remoteScrubProgress.current!.value ?? progress.value;
|
|
||||||
const updated = Math.max(
|
|
||||||
min.value,
|
|
||||||
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
|
||||||
);
|
|
||||||
remoteScrubProgress.current!.value = updated;
|
|
||||||
const progressInTicks = isVlc ? msToTicks(updated) : updated;
|
|
||||||
calculateTrickplayUrl(progressInTicks);
|
|
||||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
||||||
const seconds = progressInSeconds % 60;
|
|
||||||
setTime({ hours, minutes, seconds });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "select": {
|
|
||||||
if (
|
|
||||||
isRemoteScrubbing.current!.value &&
|
|
||||||
remoteScrubProgress.current!.value != null
|
|
||||||
) {
|
|
||||||
progress.value = remoteScrubProgress.current!.value;
|
|
||||||
|
|
||||||
const seekTarget = isVlc
|
|
||||||
? Math.max(0, remoteScrubProgress.current!.value)
|
|
||||||
: Math.max(0, ticksToSeconds(remoteScrubProgress.current!.value));
|
|
||||||
|
|
||||||
seek(seekTarget);
|
|
||||||
if (isPlaying) play();
|
|
||||||
|
|
||||||
isRemoteScrubbing.current!.value = false;
|
|
||||||
remoteScrubProgress.current!.value = null;
|
|
||||||
setShowRemoteBubble(false);
|
|
||||||
} else {
|
|
||||||
togglePlay();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "down":
|
|
||||||
case "up":
|
|
||||||
// cancel scrubbing on other directions
|
|
||||||
isRemoteScrubbing.current!.value = false;
|
|
||||||
remoteScrubProgress.current!.value = null;
|
|
||||||
setShowRemoteBubble(false);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!showControls) toggleControls();
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSeekBackward = (
|
|
||||||
seconds: number,
|
|
||||||
wasPlayingRef: React.MutableRefObject<boolean>,
|
|
||||||
) => {
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
try {
|
|
||||||
const curr = progress.value;
|
|
||||||
if (curr !== undefined) {
|
|
||||||
const newTime = isVlc
|
|
||||||
? Math.max(0, curr - secondsToMs(seconds))
|
|
||||||
: Math.max(0, ticksToSeconds(curr) - seconds);
|
|
||||||
seek(newTime);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error seeking video backwards", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeekForward = (
|
|
||||||
seconds: number,
|
|
||||||
wasPlayingRef: React.MutableRefObject<boolean>,
|
|
||||||
) => {
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
try {
|
|
||||||
const curr = progress.value;
|
|
||||||
if (curr !== undefined) {
|
|
||||||
const newTime = isVlc
|
|
||||||
? curr + secondsToMs(seconds)
|
|
||||||
: ticksToSeconds(curr) + seconds;
|
|
||||||
seek(Math.max(0, newTime));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error seeking video forwards", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Long press scrubbing effect
|
|
||||||
useEffect(() => {
|
|
||||||
let isActive = true;
|
|
||||||
let seekTime = 10;
|
|
||||||
|
|
||||||
if (longPressScrubMode) {
|
|
||||||
// Function is used, but eslint doesn't detect it inside setTimeout
|
|
||||||
const scrubWithLongPress = (
|
|
||||||
wasPlayingRef: React.MutableRefObject<boolean>,
|
|
||||||
) => {
|
|
||||||
if (!isActive || !longPressScrubMode) return;
|
|
||||||
|
|
||||||
const scrubFn =
|
|
||||||
longPressScrubMode === "FF"
|
|
||||||
? (time: number) => handleSeekForward(time, wasPlayingRef)
|
|
||||||
: (time: number) => handleSeekBackward(time, wasPlayingRef);
|
|
||||||
|
|
||||||
scrubFn(seekTime);
|
|
||||||
seekTime *= 1.1;
|
|
||||||
|
|
||||||
longPressTimeoutRef.current = setTimeout(
|
|
||||||
() => scrubWithLongPress(wasPlayingRef),
|
|
||||||
300,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start the scrubbing
|
|
||||||
const wasPlayingRef = { current: isPlaying };
|
|
||||||
scrubWithLongPress(wasPlayingRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isActive = false;
|
|
||||||
if (longPressTimeoutRef.current) {
|
|
||||||
clearTimeout(longPressTimeoutRef.current);
|
|
||||||
longPressTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [longPressScrubMode, handleSeekBackward, handleSeekForward, isPlaying]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
remoteScrubProgress: remoteScrubProgress.current,
|
|
||||||
isRemoteScrubbing: isRemoteScrubbing.current,
|
|
||||||
showRemoteBubble,
|
|
||||||
longPressScrubMode,
|
|
||||||
time,
|
|
||||||
handleSeekBackward,
|
|
||||||
handleSeekForward,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useCallback, useRef } from "react";
|
|
||||||
import type { SharedValue } from "react-native-reanimated";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { secondsToMs, ticksToSeconds } from "@/utils/time";
|
|
||||||
|
|
||||||
interface UseSkipControlsProps {
|
|
||||||
progress: SharedValue<number>;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isVlc: boolean;
|
|
||||||
seek: (ticks: number) => void;
|
|
||||||
play: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSkipControls = ({
|
|
||||||
progress,
|
|
||||||
isPlaying,
|
|
||||||
isVlc,
|
|
||||||
seek,
|
|
||||||
play,
|
|
||||||
}: UseSkipControlsProps) => {
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
const wasPlayingRef = useRef(false);
|
|
||||||
|
|
||||||
const handleSkipBackward = useCallback(async () => {
|
|
||||||
if (!settings?.rewindSkipTime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
lightHapticFeedback();
|
|
||||||
try {
|
|
||||||
const curr = progress.value;
|
|
||||||
if (curr !== undefined) {
|
|
||||||
const newTime = isVlc
|
|
||||||
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
|
||||||
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
|
||||||
seek(newTime);
|
|
||||||
if (wasPlayingRef.current) {
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
|
||||||
}
|
|
||||||
}, [settings, isPlaying, isVlc, play, seek, lightHapticFeedback]);
|
|
||||||
|
|
||||||
const handleSkipForward = useCallback(async () => {
|
|
||||||
if (!settings?.forwardSkipTime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
lightHapticFeedback();
|
|
||||||
try {
|
|
||||||
const curr = progress.value;
|
|
||||||
if (curr !== undefined) {
|
|
||||||
const newTime = isVlc
|
|
||||||
? curr + secondsToMs(settings.forwardSkipTime)
|
|
||||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
|
||||||
seek(Math.max(0, newTime));
|
|
||||||
if (wasPlayingRef.current) {
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
|
||||||
}
|
|
||||||
}, [settings, isPlaying, isVlc, play, seek, lightHapticFeedback]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleSkipBackward,
|
|
||||||
handleSkipForward,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { debounce } from "lodash";
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
type SharedValue,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
|
||||||
|
|
||||||
interface UseSliderInteractionsProps {
|
|
||||||
progress: SharedValue<number>;
|
|
||||||
isSeeking: SharedValue<boolean>;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isVlc: boolean;
|
|
||||||
showControls: boolean;
|
|
||||||
item: BaseItemDto;
|
|
||||||
seek: (ticks: number) => void;
|
|
||||||
play: () => void;
|
|
||||||
pause: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSliderInteractions = ({
|
|
||||||
progress,
|
|
||||||
isSeeking,
|
|
||||||
isPlaying,
|
|
||||||
isVlc,
|
|
||||||
showControls,
|
|
||||||
item,
|
|
||||||
seek,
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
}: UseSliderInteractionsProps) => {
|
|
||||||
const [isSliding, setIsSliding] = useState(false);
|
|
||||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
|
||||||
|
|
||||||
const wasPlayingRef = useRef(false);
|
|
||||||
const lastProgressRef = useRef<number>(0);
|
|
||||||
|
|
||||||
// Animated scale for slider
|
|
||||||
const sliderScale = useSharedValue(1);
|
|
||||||
|
|
||||||
const { calculateTrickplayUrl } = useTrickplay(item);
|
|
||||||
|
|
||||||
const handleSliderStart = useCallback(() => {
|
|
||||||
if (!showControls) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSliding(true);
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
lastProgressRef.current = progress.value;
|
|
||||||
|
|
||||||
pause();
|
|
||||||
isSeeking.value = true;
|
|
||||||
}, [showControls, isPlaying, pause]);
|
|
||||||
|
|
||||||
const handleTouchStart = useCallback(() => {
|
|
||||||
if (!showControls) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scale up the slider immediately on touch
|
|
||||||
sliderScale.value = withTiming(1.4, { duration: 300 });
|
|
||||||
}, [showControls]);
|
|
||||||
|
|
||||||
const handleTouchEnd = useCallback(() => {
|
|
||||||
if (!showControls) {
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleSliderComplete = useCallback(
|
|
||||||
async (value: number) => {
|
|
||||||
isSeeking.value = false;
|
|
||||||
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))));
|
|
||||||
if (wasPlayingRef.current) {
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isVlc, seek, play],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSliderChange = useCallback(
|
|
||||||
debounce((value: number) => {
|
|
||||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
|
||||||
calculateTrickplayUrl(progressInTicks);
|
|
||||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
||||||
const seconds = progressInSeconds % 60;
|
|
||||||
setTime({ hours, minutes, seconds });
|
|
||||||
}, 3),
|
|
||||||
[isVlc, calculateTrickplayUrl],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isSliding,
|
|
||||||
setIsSliding,
|
|
||||||
time,
|
|
||||||
sliderScale,
|
|
||||||
handleSliderStart,
|
|
||||||
handleTouchStart,
|
|
||||||
handleTouchEnd,
|
|
||||||
handleSliderComplete,
|
|
||||||
handleSliderChange,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import {
|
|
||||||
runOnJS,
|
|
||||||
type SharedValue,
|
|
||||||
useAnimatedReaction,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { ticksToSeconds } from "@/utils/time";
|
|
||||||
|
|
||||||
interface UseTimeManagementProps {
|
|
||||||
progress: SharedValue<number>;
|
|
||||||
max: SharedValue<number>;
|
|
||||||
isSeeking: SharedValue<boolean>;
|
|
||||||
isVlc: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTimeManagement = ({
|
|
||||||
progress,
|
|
||||||
max,
|
|
||||||
isSeeking,
|
|
||||||
isVlc,
|
|
||||||
}: UseTimeManagementProps) => {
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
|
||||||
(currentProgress: number, maxValue: number) => {
|
|
||||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
|
||||||
const remaining = isVlc
|
|
||||||
? maxValue - currentProgress
|
|
||||||
: ticksToSeconds(maxValue - currentProgress);
|
|
||||||
|
|
||||||
setCurrentTime(current);
|
|
||||||
setRemainingTime(remaining);
|
|
||||||
},
|
|
||||||
[isVlc],
|
|
||||||
);
|
|
||||||
|
|
||||||
useAnimatedReaction(
|
|
||||||
() => ({
|
|
||||||
progress: progress.value,
|
|
||||||
max: max.value,
|
|
||||||
isSeeking: isSeeking.value,
|
|
||||||
}),
|
|
||||||
(result) => {
|
|
||||||
if (!result.isSeeking) {
|
|
||||||
runOnJS(updateTimes)(result.progress, result.max);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[updateTimes],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getEndTime = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
|
|
||||||
const finishTime = new Date(now.getTime() + remainingMs);
|
|
||||||
return finishTime.toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentTime,
|
|
||||||
remainingTime,
|
|
||||||
updateTimes,
|
|
||||||
getEndTime,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { type Dispatch, type SetStateAction, useCallback } from "react";
|
|
||||||
import type { ScaleFactor } from "../ScaleFactorSelector";
|
|
||||||
import type { AspectRatio } from "../VideoScalingModeSelector";
|
|
||||||
|
|
||||||
interface UseVideoScalingProps {
|
|
||||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
|
||||||
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
|
|
||||||
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
|
||||||
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useVideoScaling = ({
|
|
||||||
setAspectRatio,
|
|
||||||
setScaleFactor,
|
|
||||||
setVideoAspectRatio,
|
|
||||||
setVideoScaleFactor,
|
|
||||||
}: UseVideoScalingProps) => {
|
|
||||||
const handleAspectRatioChange = useCallback(
|
|
||||||
async (newRatio: AspectRatio) => {
|
|
||||||
if (!setAspectRatio || !setVideoAspectRatio) return;
|
|
||||||
|
|
||||||
setAspectRatio(newRatio);
|
|
||||||
const aspectRatioString = newRatio === "default" ? null : newRatio;
|
|
||||||
await setVideoAspectRatio(aspectRatioString);
|
|
||||||
},
|
|
||||||
[setAspectRatio, setVideoAspectRatio],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleScaleFactorChange = useCallback(
|
|
||||||
async (newScale: ScaleFactor) => {
|
|
||||||
if (!setScaleFactor || !setVideoScaleFactor) return;
|
|
||||||
|
|
||||||
setScaleFactor(newScale);
|
|
||||||
await setVideoScaleFactor(newScale);
|
|
||||||
},
|
|
||||||
[setScaleFactor, setVideoScaleFactor],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleAspectRatioChange,
|
|
||||||
handleScaleFactorChange,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { ticksToMs } from "@/utils/time";
|
|
||||||
|
|
||||||
export const initializeProgress = (item: BaseItemDto, isVlc: boolean) => {
|
|
||||||
const initialProgress = isVlc
|
|
||||||
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
|
||||||
: item?.UserData?.PlaybackPositionTicks || 0;
|
|
||||||
|
|
||||||
const maxProgress = isVlc
|
|
||||||
? ticksToMs(item.RunTimeTicks || 0)
|
|
||||||
: item.RunTimeTicks || 0;
|
|
||||||
|
|
||||||
return { initialProgress, maxProgress };
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { TRICKPLAY_TILE_SCALE, TRICKPLAY_TILE_WIDTH } from "../constants";
|
|
||||||
|
|
||||||
export const calculateTrickplayDimensions = (aspectRatio: number) => {
|
|
||||||
const tileWidth = TRICKPLAY_TILE_WIDTH;
|
|
||||||
const tileHeight = TRICKPLAY_TILE_WIDTH / aspectRatio;
|
|
||||||
|
|
||||||
return {
|
|
||||||
tileWidth,
|
|
||||||
tileHeight,
|
|
||||||
scaledWidth: tileWidth * TRICKPLAY_TILE_SCALE,
|
|
||||||
scaledHeight: tileHeight * TRICKPLAY_TILE_SCALE,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatTimeForBubble = (time: {
|
|
||||||
hours: number;
|
|
||||||
minutes: number;
|
|
||||||
seconds: number;
|
|
||||||
}) => {
|
|
||||||
return `${time.hours > 0 ? `${time.hours}:` : ""}${
|
|
||||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
|
||||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
|
|
||||||
};
|
|
||||||
6
eas.json
6
eas.json
@@ -46,14 +46,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.32.0",
|
"channel": "0.32.1",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.32.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.32.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({
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
"react-native-pager-view": "^6.9.1",
|
"react-native-pager-view": "^6.9.1",
|
||||||
"react-native-reanimated": "~3.16.7",
|
"react-native-reanimated": "~3.16.7",
|
||||||
"react-native-reanimated-carousel": "4.0.2",
|
"react-native-reanimated-carousel": "4.0.2",
|
||||||
"react-native-safe-area-context": "5.6.1",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
"react-native-screens": "~4.11.1",
|
"react-native-screens": "~4.11.1",
|
||||||
"react-native-svg": "15.11.2",
|
"react-native-svg": "15.11.2",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.32.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.32.0"`,
|
}, DeviceId="${deviceId}", Version="0.32.1"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user