diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 95a39b31..a9429dc2 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -96,22 +96,30 @@ export default function CastingPlayerScreen() { const [fetchedItem, setFetchedItem] = useState(null); useEffect(() => { + const controller = new AbortController(); + const fetchItemData = async () => { const itemId = mediaStatus?.mediaInfo?.contentId; if (!itemId || !api || !user?.Id) return; try { - const res = await getUserLibraryApi(api).getItem({ - itemId, - userId: user.Id, - }); - setFetchedItem(res.data); + const res = await getUserLibraryApi(api).getItem( + { itemId, userId: user.Id }, + { signal: controller.signal }, + ); + if (!controller.signal.aborted) { + setFetchedItem(res.data); + } } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") + return; console.error("[Casting Player] Failed to fetch item:", error); } }; fetchItemData(); + + return () => controller.abort(); }, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]); useEffect(() => { diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index 90c0ad4a..d8fadbd2 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -156,6 +156,8 @@ export function Chromecast({ user?.Id, mediaStatus?.streamPosition, mediaStatus?.mediaInfo?.contentId, + mediaStatus?.playerState, + mediaStatus?.mediaInfo?.contentUrl, ]); // Android requires the cast button to be present for startDiscovery to work diff --git a/components/casting/CastingMiniPlayer.tsx b/components/casting/CastingMiniPlayer.tsx index f2a7b021..5cdbd28a 100644 --- a/components/casting/CastingMiniPlayer.tsx +++ b/components/casting/CastingMiniPlayer.tsx @@ -63,27 +63,41 @@ export const CastingMiniPlayer: React.FC = () => { mediaStatus?.streamPosition || 0, ); + // Track baseline for elapsed-time computation + const baselinePositionRef = useRef(mediaStatus?.streamPosition || 0); + const baselineTimestampRef = useRef(Date.now()); + // Sync live progress with mediaStatus and poll every second when playing useEffect(() => { - if (mediaStatus?.streamPosition) { + // Resync baseline whenever mediaStatus reports a new position + if (mediaStatus?.streamPosition !== undefined) { + baselinePositionRef.current = mediaStatus.streamPosition; + baselineTimestampRef.current = Date.now(); setLiveProgress(mediaStatus.streamPosition); } - // Update every second when playing + // Update based on elapsed real time when playing const interval = setInterval(() => { - if ( - mediaStatus?.playerState === MediaPlayerState.PLAYING && - mediaStatus?.streamPosition !== undefined - ) { - setLiveProgress((prev) => prev + 1); + if (mediaStatus?.playerState === MediaPlayerState.PLAYING) { + const elapsed = + ((Date.now() - baselineTimestampRef.current) * + (mediaStatus.playbackRate || 1)) / + 1000; + setLiveProgress(baselinePositionRef.current + elapsed); } else if (mediaStatus?.streamPosition !== undefined) { // Sync with actual position when paused/buffering + baselinePositionRef.current = mediaStatus.streamPosition; + baselineTimestampRef.current = Date.now(); setLiveProgress(mediaStatus.streamPosition); } }, 1000); return () => clearInterval(interval); - }, [mediaStatus?.playerState, mediaStatus?.streamPosition]); + }, [ + mediaStatus?.playerState, + mediaStatus?.streamPosition, + mediaStatus?.playbackRate, + ]); const progress = liveProgress * 1000; // Convert to ms const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000; @@ -425,7 +439,13 @@ export const CastingMiniPlayer: React.FC = () => { {/* Play/Pause button */} - + { + e.stopPropagation(); + handleTogglePlayPause(); + }} + style={{ padding: 8 }} + > { isSliding.current = true; }} - onValueChange={(value) => { + onValueChange={async (value) => { volumeValue.value = value; handleVolumeChange(value); if (isMuted) { setIsMuted(false); try { - castSession?.setMute(false); + await castSession?.setMute(false); } catch (error: unknown) { console.error( "[ChromecastConnectionMenu] Failed to unmute:", diff --git a/components/chromecast/ChromecastDeviceSheet.tsx b/components/chromecast/ChromecastDeviceSheet.tsx index 2ce7dea1..184c818e 100644 --- a/components/chromecast/ChromecastDeviceSheet.tsx +++ b/components/chromecast/ChromecastDeviceSheet.tsx @@ -278,13 +278,18 @@ export const ChromecastDeviceSheet: React.FC = ({ onSlidingStart={() => { isSliding.current = true; }} - onValueChange={(value) => { + onValueChange={async (value) => { volumeValue.value = value; handleVolumeChange(value); // Unmute when adjusting volume if (isMuted) { setIsMuted(false); - castSession?.setMute(false); + try { + await castSession?.setMute(false); + } catch (error) { + console.error("[Volume] Failed to unmute:", error); + setIsMuted(true); // Rollback on failure + } } }} onSlidingComplete={(value) => { diff --git a/components/chromecast/ChromecastEpisodeList.tsx b/components/chromecast/ChromecastEpisodeList.tsx index 4967cda2..7b6c7ef1 100644 --- a/components/chromecast/ChromecastEpisodeList.tsx +++ b/components/chromecast/ChromecastEpisodeList.tsx @@ -36,6 +36,9 @@ export const ChromecastEpisodeList: React.FC = ({ const { t } = useTranslation(); const flatListRef = useRef(null); const [selectedSeason, setSelectedSeason] = useState(null); + const scrollRetryCountRef = useRef(0); + const scrollRetryTimeoutRef = useRef(null); + const MAX_SCROLL_RETRIES = 3; // Get unique seasons from episodes const seasons = useMemo(() => { @@ -72,6 +75,12 @@ export const ChromecastEpisodeList: React.FC = ({ }, [currentItem]); useEffect(() => { + // Reset retry counter when visibility or data changes + scrollRetryCountRef.current = 0; + if (scrollRetryTimeoutRef.current) { + clearTimeout(scrollRetryTimeoutRef.current); + } + if (visible && currentItem && filteredEpisodes.length > 0) { const currentIndex = filteredEpisodes.findIndex( (ep) => ep.Id === currentItem.Id, @@ -85,7 +94,12 @@ export const ChromecastEpisodeList: React.FC = ({ viewPosition: 0.5, // Center the item }); }, 300); - return () => clearTimeout(timeoutId); + return () => { + clearTimeout(timeoutId); + if (scrollRetryTimeoutRef.current) { + clearTimeout(scrollRetryTimeoutRef.current); + } + }; } } }, [visible, currentItem, filteredEpisodes]); @@ -117,26 +131,30 @@ export const ChromecastEpisodeList: React.FC = ({ backgroundColor: "#1a1a1a", }} > - {api && item.Id && ( - - )} - {(!api || !item.Id) && ( - - - - )} + {(() => { + const imageUrl = + api && item.Id ? getPrimaryImageUrl({ api, item }) : null; + if (imageUrl) { + return ( + + ); + } + return ( + + + + ); + })()} {/* Episode info */} @@ -150,7 +168,7 @@ export const ChromecastEpisodeList: React.FC = ({ }} numberOfLines={1} > - {item.IndexNumber}.{" "} + {item.IndexNumber != null ? `${item.IndexNumber}. ` : ""} {truncateTitle(item.Name || t("casting_player.unknown"), 30)} {item.Overview && ( @@ -295,8 +313,18 @@ export const ChromecastEpisodeList: React.FC = ({ }} showsVerticalScrollIndicator={false} onScrollToIndexFailed={(info) => { - // Fallback if scroll fails - setTimeout(() => { + // Bounded retry for scroll failures + if ( + scrollRetryCountRef.current >= MAX_SCROLL_RETRIES || + info.index >= filteredEpisodes.length + ) { + return; + } + scrollRetryCountRef.current += 1; + if (scrollRetryTimeoutRef.current) { + clearTimeout(scrollRetryTimeoutRef.current); + } + scrollRetryTimeoutRef.current = setTimeout(() => { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, diff --git a/components/chromecast/ChromecastSettingsMenu.tsx b/components/chromecast/ChromecastSettingsMenu.tsx index 450d1543..4e1eb5cd 100644 --- a/components/chromecast/ChromecastSettingsMenu.tsx +++ b/components/chromecast/ChromecastSettingsMenu.tsx @@ -286,11 +286,11 @@ export const ChromecastSettingsMenu: React.FC = ({ track.language || t("casting_player.unknown")} - {track.codec && ( + {(track.codec || track.isForced) && ( - {track.codec.toUpperCase()} + {track.codec ? track.codec.toUpperCase() : ""} {track.isForced && ` • ${t("casting_player.forced")}`} )} diff --git a/components/chromecast/hooks/useChromecastSegments.ts b/components/chromecast/hooks/useChromecastSegments.ts index a83ed162..4b94b6af 100644 --- a/components/chromecast/hooks/useChromecastSegments.ts +++ b/components/chromecast/hooks/useChromecastSegments.ts @@ -78,6 +78,8 @@ export const useChromecastSegments = ( }, [segmentData]); // Check which segment we're currently in + // currentProgressMs is in milliseconds; isWithinSegment() converts ms→seconds internally + // before comparing with segment times (which are in seconds from the autoskip API) const currentSegment = useMemo(() => { if (isWithinSegment(currentProgressMs, segments.intro)) { return { type: "intro" as const, segment: segments.intro }; diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 2e7e89a4..1b00d095 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -124,6 +124,12 @@ export const Controls: FC = ({ // Ref to track pending play timeout for cleanup and cancellation const playTimeoutRef = useRef | null>(null); + // Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout + const playingRef = useRef(isPlaying); + useEffect(() => { + playingRef.current = isPlaying; + }, [isPlaying]); + // Clean up timeout on unmount useEffect(() => { return () => { @@ -346,15 +352,15 @@ export const Controls: FC = ({ seek(timeInSeconds * 1000); // Brief delay ensures the seek operation completes before resuming playback // Without this, playback may resume from the old position - // Only resume if currently playing to avoid overriding user pause - if (isPlaying) { - playTimeoutRef.current = setTimeout(() => { + // Read latest isPlaying from ref to avoid stale closure + playTimeoutRef.current = setTimeout(() => { + if (playingRef.current) { play(); - playTimeoutRef.current = null; - }, 200); - } + } + playTimeoutRef.current = null; + }, 200); }, - [seek, play, isPlaying], + [seek, play], ); // Use unified segment skipper for all segment types diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts index 78021831..b8b2ed46 100644 --- a/hooks/useCasting.ts +++ b/hooks/useCasting.ts @@ -36,7 +36,6 @@ export const useCasting = (item: BaseItemDto | null) => { // Local state const [state, setState] = useState(DEFAULT_CAST_STATE); - const progressIntervalRef = useRef(null); const controlsTimeoutRef = useRef(null); const lastReportedProgressRef = useRef(0); const volumeDebounceRef = useRef(null); @@ -125,6 +124,9 @@ export const useCasting = (item: BaseItemDto | null) => { // Report playback start when media begins (only once per item) const currentState = stateRef.current; if (hasReportedStartRef.current !== item.Id && currentState.progress > 0) { + // Set synchronously before async call to prevent race condition duplicates + hasReportedStartRef.current = item.Id || null; + playStateApi .reportPlaybackStart({ playbackStartInfo: { @@ -137,10 +139,9 @@ export const useCasting = (item: BaseItemDto | null) => { PlaySessionId: mediaStatus?.mediaInfo?.contentId, }, }) - .then(() => { - hasReportedStartRef.current = item.Id || null; - }) .catch((error) => { + // Revert on failure so it can be retried + hasReportedStartRef.current = null; console.error("[useCasting] Failed to report playback start:", error); }); } @@ -217,7 +218,12 @@ export const useCasting = (item: BaseItemDto | null) => { const pause = useCallback(async () => { if (activeProtocol === "chromecast") { - await client?.pause(); + try { + await client?.pause(); + } catch (error) { + console.error("[useCasting] Error pausing:", error); + throw error; + } } // Future: Add pause control for other protocols }, [client, activeProtocol]); @@ -244,8 +250,9 @@ export const useCasting = (item: BaseItemDto | null) => { // Additional validation for Chromecast if (activeProtocol === "chromecast") { // state.duration is in ms, positionSeconds is in seconds - compare in same unit + // Only clamp when duration is known (> 0) to avoid forcing seeks to 0 const durationSeconds = state.duration / 1000; - if (positionSeconds > durationSeconds) { + if (durationSeconds > 0 && positionSeconds > durationSeconds) { console.warn( "[useCasting] Seek position exceeds duration, clamping:", positionSeconds, @@ -281,31 +288,35 @@ export const useCasting = (item: BaseItemDto | null) => { // Stop and disconnect const stop = useCallback( async (onStopComplete?: () => void) => { - if (activeProtocol === "chromecast") { - await client?.stop(); - } - // Future: Add stop control for other protocols + try { + if (activeProtocol === "chromecast") { + await client?.stop(); + } + // Future: Add stop control for other protocols - // Report stop to Jellyfin - if (api && item?.Id && user?.Id) { - const playStateApi = getPlaystateApi(api); - await playStateApi.reportPlaybackStopped({ - playbackStopInfo: { - ItemId: item.Id, - PositionTicks: state.progress * 10000, - }, - }); - } + // Report stop to Jellyfin + if (api && item?.Id && user?.Id) { + const playStateApi = getPlaystateApi(api); + await playStateApi.reportPlaybackStopped({ + playbackStopInfo: { + ItemId: item.Id, + PositionTicks: stateRef.current.progress * 10000, + }, + }); + } + } catch (error) { + console.error("[useCasting] Error during stop:", error); + } finally { + setState(DEFAULT_CAST_STATE); + stateRef.current = DEFAULT_CAST_STATE; - setState(DEFAULT_CAST_STATE); - stateRef.current = DEFAULT_CAST_STATE; - - // Call callback after stop completes (e.g., to navigate away) - if (onStopComplete) { - onStopComplete(); + // Call callback after stop completes (e.g., to navigate away) + if (onStopComplete) { + onStopComplete(); + } } }, - [client, api, item?.Id, user?.Id, state.progress, activeProtocol], + [client, api, item?.Id, user?.Id, activeProtocol], ); // Volume control (debounced to reduce API calls) @@ -343,11 +354,12 @@ export const useCasting = (item: BaseItemDto | null) => { clearTimeout(controlsTimeoutRef.current); } controlsTimeoutRef.current = setTimeout(() => { - if (state.isPlaying) { + // Read latest isPlaying from stateRef to avoid stale closure + if (stateRef.current.isPlaying) { updateState((prev) => ({ ...prev, showControls: false })); } }, 5000); - }, [state.isPlaying, updateState]); + }, [updateState]); const hideControls = useCallback(() => { updateState((prev) => ({ ...prev, showControls: false })); @@ -359,9 +371,6 @@ export const useCasting = (item: BaseItemDto | null) => { // Cleanup useEffect(() => { return () => { - if (progressIntervalRef.current) { - clearInterval(progressIntervalRef.current); - } if (controlsTimeoutRef.current) { clearTimeout(controlsTimeoutRef.current); } diff --git a/utils/casting/helpers.ts b/utils/casting/helpers.ts index a6a154e5..a53c94ba 100644 --- a/utils/casting/helpers.ts +++ b/utils/casting/helpers.ts @@ -50,7 +50,7 @@ export const calculateEndingTime = ( * Determine connection quality based on bitrate */ export const getConnectionQuality = (bitrate?: number): ConnectionQuality => { - if (!bitrate) return "good"; + if (bitrate == null) return "good"; const mbps = bitrate / 1000000; if (mbps >= 15) return "excellent"; diff --git a/utils/casting/mediaInfo.ts b/utils/casting/mediaInfo.ts index 4ca0e5c4..73224583 100644 --- a/utils/casting/mediaInfo.ts +++ b/utils/casting/mediaInfo.ts @@ -21,6 +21,11 @@ export const buildCastMediaInfo = ({ streamUrl: string; api: Api; }) => { + if (!item.Id) { + throw new Error("Missing item.Id for media load — cannot build contentId"); + } + + const itemId: string = item.Id; const streamDuration = item.RunTimeTicks ? item.RunTimeTicks / 10000000 : undefined; @@ -74,7 +79,7 @@ export const buildCastMediaInfo = ({ }; return { - contentId: item.Id, + contentId: itemId, contentUrl: streamUrl, contentType: "video/mp4", streamType: MediaStreamType.BUFFERED, diff --git a/utils/chromecast/options.ts b/utils/chromecast/options.ts index 510f3753..3c68d2e6 100644 --- a/utils/chromecast/options.ts +++ b/utils/chromecast/options.ts @@ -3,22 +3,22 @@ */ export const CHROMECAST_CONSTANTS = { - // Timing - PROGRESS_REPORT_INTERVAL: 10, // seconds - CONTROLS_TIMEOUT: 5000, // ms - BUFFERING_THRESHOLD: 10, // seconds of buffer before hiding indicator - NEXT_EPISODE_COUNTDOWN_START: 30, // seconds before end - CONNECTION_CHECK_INTERVAL: 5000, // ms + // Timing (all milliseconds for consistency) + PROGRESS_REPORT_INTERVAL_MS: 10_000, + CONTROLS_TIMEOUT_MS: 5_000, + BUFFERING_THRESHOLD_MS: 10_000, + NEXT_EPISODE_COUNTDOWN_MS: 30_000, + CONNECTION_CHECK_INTERVAL_MS: 5_000, // UI POSTER_WIDTH: 300, POSTER_HEIGHT: 450, MINI_PLAYER_HEIGHT: 80, - SKIP_FORWARD_TIME: 15, // seconds (overridden by settings) - SKIP_BACKWARD_TIME: 15, // seconds (overridden by settings) + SKIP_FORWARD_SECS: 15, // overridden by settings + SKIP_BACKWARD_SECS: 15, // overridden by settings // Animation - ANIMATION_DURATION: 300, // ms + ANIMATION_DURATION_MS: 300, BLUR_RADIUS: 10, } as const; @@ -31,13 +31,12 @@ export const CONNECTION_QUALITY = { export type ConnectionQuality = keyof typeof CONNECTION_QUALITY; +export type PlaybackState = "playing" | "paused" | "stopped" | "buffering"; + export interface ChromecastPlayerState { isConnected: boolean; deviceName: string | null; - isPlaying: boolean; - isPaused: boolean; - isStopped: boolean; - isBuffering: boolean; + playbackState: PlaybackState; progress: number; // milliseconds duration: number; // milliseconds volume: number; // 0-1 @@ -57,10 +56,7 @@ export interface ChromecastSegmentData { export const DEFAULT_CHROMECAST_STATE: ChromecastPlayerState = { isConnected: false, deviceName: null, - isPlaying: false, - isPaused: false, - isStopped: true, - isBuffering: false, + playbackState: "stopped", progress: 0, duration: 0, volume: 1, diff --git a/utils/segments.ts b/utils/segments.ts index 9b2cd856..d3bcd6a0 100644 --- a/utils/segments.ts +++ b/utils/segments.ts @@ -185,38 +185,31 @@ const fetchLegacySegments = async ( const introSegments: MediaTimeSegment[] = []; const creditSegments: MediaTimeSegment[] = []; - try { - const [introRes, creditRes] = await Promise.allSettled([ - api.axiosInstance.get( - `${api.basePath}/Episode/${itemId}/IntroTimestamps`, - { headers: getAuthHeaders(api) }, - ), - api.axiosInstance.get( - `${api.basePath}/Episode/${itemId}/Timestamps`, - { headers: getAuthHeaders(api) }, - ), - ]); + const [introRes, creditRes] = await Promise.allSettled([ + api.axiosInstance.get( + `${api.basePath}/Episode/${itemId}/IntroTimestamps`, + { headers: getAuthHeaders(api) }, + ), + api.axiosInstance.get( + `${api.basePath}/Episode/${itemId}/Timestamps`, + { headers: getAuthHeaders(api) }, + ), + ]); - if (introRes.status === "fulfilled" && introRes.value.data.Valid) { - introSegments.push({ - startTime: introRes.value.data.IntroStart, - endTime: introRes.value.data.IntroEnd, - text: "Intro", - }); - } + if (introRes.status === "fulfilled" && introRes.value.data.Valid) { + introSegments.push({ + startTime: introRes.value.data.IntroStart, + endTime: introRes.value.data.IntroEnd, + text: "Intro", + }); + } - if ( - creditRes.status === "fulfilled" && - creditRes.value.data.Credits.Valid - ) { - creditSegments.push({ - startTime: creditRes.value.data.Credits.Start, - endTime: creditRes.value.data.Credits.End, - text: "Credits", - }); - } - } catch (error) { - console.error("Failed to fetch legacy segments", error); + if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) { + creditSegments.push({ + startTime: creditRes.value.data.Credits.Start, + endTime: creditRes.value.data.Credits.End, + text: "Credits", + }); } return {