mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 03:58:36 +01:00
refactor(casting): extract useCastPlayerProgress hook
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
import { router, Stack } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
@@ -17,7 +17,7 @@ import GoogleCast, {
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import Animated, { useSharedValue } from "react-native-reanimated";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { CastPlayerEpisodeControls } from "@/components/casting/player/CastPlayerEpisodeControls";
|
||||
@@ -35,8 +35,8 @@ import { useCastDismissGesture } from "@/hooks/useCastDismissGesture";
|
||||
import { useCastEpisodes } from "@/hooks/useCastEpisodes";
|
||||
import { useCasting } from "@/hooks/useCasting";
|
||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
||||
import { useCastPlayerProgress } from "@/hooks/useCastPlayerProgress";
|
||||
import { useCastSelection } from "@/hooks/useCastSelection";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { detectCapabilities } from "@/utils/casting/capabilities";
|
||||
@@ -59,30 +59,6 @@ export default function CastingPlayerScreen() {
|
||||
// Keep hook active for connection - used by remoteMediaClient from useCasting
|
||||
useRemoteMediaClient();
|
||||
|
||||
// Shared values for progress slider (must be initialized before any early returns)
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(100);
|
||||
const isScrubbing = useRef(false);
|
||||
|
||||
// Trickplay time display
|
||||
const [trickplayTime, setTrickplayTime] = useState({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
|
||||
// Track scrub percentage for trickplay bubble positioning
|
||||
const [scrubPercentage, setScrubPercentage] = useState(0);
|
||||
|
||||
// Live progress tracking - update every second
|
||||
const [liveProgress, setLiveProgress] = useState(0);
|
||||
const lastSyncPositionRef = useRef(0);
|
||||
const lastSyncTimestampRef = useRef(Date.now());
|
||||
|
||||
// Last stable playback position (seconds), for resuming across reloads.
|
||||
const resumePositionRef = useRef(0);
|
||||
|
||||
// Fetch full item data from Jellyfin by ID and derive the effective item
|
||||
const { fetchedItem, currentItem } = useCastPlayerItem({
|
||||
api,
|
||||
@@ -90,64 +66,29 @@ export default function CastingPlayerScreen() {
|
||||
mediaStatus,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Sync refs whenever mediaStatus provides a new position
|
||||
if (mediaStatus?.streamPosition !== undefined) {
|
||||
lastSyncPositionRef.current = mediaStatus.streamPosition;
|
||||
lastSyncTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
|
||||
// Update every second when playing, deriving from last sync point
|
||||
const interval = setInterval(() => {
|
||||
if (
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
|
||||
mediaStatus?.streamPosition !== undefined
|
||||
) {
|
||||
const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000;
|
||||
setLiveProgress(lastSyncPositionRef.current + elapsed);
|
||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||
// Sync with actual position when paused/buffering
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
|
||||
|
||||
// Track the last stable position so a reload mid-switch resumes correctly.
|
||||
useEffect(() => {
|
||||
const pos = mediaStatus?.streamPosition ?? 0;
|
||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING && pos > 0) {
|
||||
resumePositionRef.current = pos;
|
||||
}
|
||||
}, [mediaStatus?.streamPosition, mediaStatus?.playerState]);
|
||||
|
||||
// Derive state from raw Chromecast hooks
|
||||
const progress = liveProgress; // Use live-updating progress
|
||||
const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0;
|
||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
||||
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
||||
const currentDevice = castDevice?.friendlyName ?? null;
|
||||
|
||||
// Trickplay for seeking preview - use fetched item with full data
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
fetchedItem ?? null,
|
||||
);
|
||||
|
||||
// Update slider max when duration changes
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
sliderMax.value = duration * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [duration, sliderMax]);
|
||||
|
||||
// Update slider progress when not scrubbing
|
||||
useEffect(() => {
|
||||
if (!isScrubbing.current && progress > 0) {
|
||||
sliderProgress.value = progress * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [progress, sliderProgress]);
|
||||
// Progress/slider/trickplay cluster: slider shared values, scrub state,
|
||||
// live-progress interpolation, resume-position tracking, trickplay preview.
|
||||
const {
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
scrubPercentage,
|
||||
setScrubPercentage,
|
||||
progress,
|
||||
resumePositionRef,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
} = useCastPlayerProgress({ mediaStatus, fetchedItem, duration });
|
||||
|
||||
// Only use casting controls if we have a current item to avoid "No session" errors
|
||||
const castingControls = useCasting(currentItem);
|
||||
|
||||
160
hooks/useCastPlayerProgress.ts
Normal file
160
hooks/useCastPlayerProgress.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||
import { MediaPlayerState, type MediaStatus } from "react-native-google-cast";
|
||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
|
||||
interface TrickplayTime {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
interface UseCastPlayerProgressParams {
|
||||
/** Raw Chromecast media status, or null when no session. */
|
||||
mediaStatus: MediaStatus | null;
|
||||
/** Full item fetched from Jellyfin, used to derive trickplay data. */
|
||||
fetchedItem: BaseItemDto | null;
|
||||
/** Total media duration, in seconds. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
||||
|
||||
interface UseCastPlayerProgressResult {
|
||||
/** Shared value tracking the slider progress, in milliseconds. */
|
||||
sliderProgress: SharedValue<number>;
|
||||
/** Shared value for the slider minimum, in milliseconds. */
|
||||
sliderMin: SharedValue<number>;
|
||||
/** Shared value for the slider maximum, in milliseconds. */
|
||||
sliderMax: SharedValue<number>;
|
||||
/** Mutable ref flag set true while the user is scrubbing. */
|
||||
isScrubbing: RefObject<boolean>;
|
||||
/** Trickplay time display state for the bubble. */
|
||||
trickplayTime: TrickplayTime;
|
||||
/** Updates the trickplay time display state. */
|
||||
setTrickplayTime: (time: TrickplayTime) => void;
|
||||
/** Current scrub percentage (0-1), used to position the trickplay bubble. */
|
||||
scrubPercentage: number;
|
||||
/** Updates the scrub percentage. */
|
||||
setScrubPercentage: (value: number) => void;
|
||||
/** Current playback progress, in seconds (live-updating). */
|
||||
progress: number;
|
||||
/** Live-updating playback position, in seconds. */
|
||||
liveProgress: number;
|
||||
/** Last stable playback position (seconds), for resuming across reloads. */
|
||||
resumePositionRef: RefObject<number>;
|
||||
/** Current trickplay image URL/coordinates, or null. */
|
||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
||||
/** Computes the trickplay URL for a given progress in ticks. */
|
||||
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
||||
/** Parsed trickplay metadata, or null. */
|
||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress/slider/trickplay cluster for the casting player.
|
||||
* Owns the slider shared values, scrub state, live-progress interpolation,
|
||||
* resume-position tracking, and trickplay preview.
|
||||
*/
|
||||
export function useCastPlayerProgress({
|
||||
mediaStatus,
|
||||
fetchedItem,
|
||||
duration,
|
||||
}: UseCastPlayerProgressParams): UseCastPlayerProgressResult {
|
||||
// Shared values for progress slider (must be initialized before any early returns)
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(100);
|
||||
const isScrubbing = useRef(false);
|
||||
|
||||
// Trickplay time display
|
||||
const [trickplayTime, setTrickplayTime] = useState({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
|
||||
// Track scrub percentage for trickplay bubble positioning
|
||||
const [scrubPercentage, setScrubPercentage] = useState(0);
|
||||
|
||||
// Live progress tracking - update every second
|
||||
const [liveProgress, setLiveProgress] = useState(0);
|
||||
const lastSyncPositionRef = useRef(0);
|
||||
const lastSyncTimestampRef = useRef(Date.now());
|
||||
|
||||
// Last stable playback position (seconds), for resuming across reloads.
|
||||
const resumePositionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync refs whenever mediaStatus provides a new position
|
||||
if (mediaStatus?.streamPosition !== undefined) {
|
||||
lastSyncPositionRef.current = mediaStatus.streamPosition;
|
||||
lastSyncTimestampRef.current = Date.now();
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
|
||||
// Update every second when playing, deriving from last sync point
|
||||
const interval = setInterval(() => {
|
||||
if (
|
||||
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
|
||||
mediaStatus?.streamPosition !== undefined
|
||||
) {
|
||||
const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000;
|
||||
setLiveProgress(lastSyncPositionRef.current + elapsed);
|
||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
||||
// Sync with actual position when paused/buffering
|
||||
setLiveProgress(mediaStatus.streamPosition);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
|
||||
|
||||
// Track the last stable position so a reload mid-switch resumes correctly.
|
||||
useEffect(() => {
|
||||
const pos = mediaStatus?.streamPosition ?? 0;
|
||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING && pos > 0) {
|
||||
resumePositionRef.current = pos;
|
||||
}
|
||||
}, [mediaStatus?.streamPosition, mediaStatus?.playerState]);
|
||||
|
||||
// Derive state from raw Chromecast hooks
|
||||
const progress = liveProgress; // Use live-updating progress
|
||||
|
||||
// Trickplay for seeking preview - use fetched item with full data
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
fetchedItem ?? null,
|
||||
);
|
||||
|
||||
// Update slider max when duration changes
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
sliderMax.value = duration * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [duration, sliderMax]);
|
||||
|
||||
// Update slider progress when not scrubbing
|
||||
useEffect(() => {
|
||||
if (!isScrubbing.current && progress > 0) {
|
||||
sliderProgress.value = progress * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [progress, sliderProgress]);
|
||||
|
||||
return {
|
||||
sliderProgress,
|
||||
sliderMin,
|
||||
sliderMax,
|
||||
isScrubbing,
|
||||
trickplayTime,
|
||||
setTrickplayTime,
|
||||
scrubPercentage,
|
||||
setScrubPercentage,
|
||||
progress,
|
||||
liveProgress,
|
||||
resumePositionRef,
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user