diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 75c06509..e16111e7 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -168,10 +168,18 @@ export default function CastingPlayerScreen() { // Priority 3: Create minimal fallback while loading if (mediaStatus?.mediaInfo) { const { contentId, metadata } = mediaStatus.mediaInfo; + // Derive type from metadata if available, otherwise omit to avoid + // misrepresenting episodes as movies + let metadataType: string | undefined; + if (metadata?.type === "movie") { + metadataType = "Movie"; + } else if (metadata?.type === "tvShow") { + metadataType = "Episode"; + } return { Id: contentId, Name: metadata?.title || "Unknown", - Type: "Movie", // Temporary until API fetch completes + ...(metadataType ? { Type: metadataType } : {}), ServerId: "", } as BaseItemDto; } @@ -188,7 +196,7 @@ export default function CastingPlayerScreen() { // Trickplay for seeking preview - use fetched item with full data const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( - fetchedItem ?? ({} as BaseItemDto), + fetchedItem ?? null, ); // Update slider max when duration changes @@ -494,13 +502,10 @@ export default function CastingPlayerScreen() { api, ]); - // Auto-navigate to player when casting starts (if not already on player screen) - useEffect(() => { - if (mediaStatus?.currentItemId && !currentItem) { - // New media started casting while we're not on the player - router.replace("/casting-player" as const); - } - }, [mediaStatus?.currentItemId, currentItem, router]); + // NOTE: Auto-navigation to casting-player is handled by higher-level + // components (e.g., CastingMiniPlayer or Chromecast button). We intentionally + // do NOT call router.replace("/casting-player") here because this component + // IS the casting-player screen — doing so would cause redundant navigation loops. // Segment detection (skip intro/credits) - use progress in seconds for accurate detection const { currentSegment, skipIntro, skipCredits, skipSegment } = @@ -1275,7 +1280,7 @@ export default function CastingPlayerScreen() { color='white' style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }} /> - {!!settings?.rewindSkipTime && ( + {settings?.rewindSkipTime != null && ( - {!!settings?.forwardSkipTime && ( + {settings?.forwardSkipTime != null && ( { const hex = b.toString(16).padStart(2, "0"); - return [3, 5, 7, 9].includes(i) ? `-${hex}` : hex; + return [4, 6, 8, 10].includes(i) ? `-${hex}` : hex; }).join(""); playSessionIdRef.current = uuid; lastContentIdRef.current = contentId; @@ -130,7 +130,7 @@ export function Chromecast({ const positionTicks = Math.floor(streamPosition * 10000000); const isPaused = mediaStatus.playerState === "paused"; const streamUrl = mediaStatus.mediaInfo.contentUrl || ""; - const isTranscoding = streamUrl.includes("m3u8"); + const isTranscoding = /m3u8/i.test(streamUrl); const progressInfo: PlaybackProgressInfo = { ItemId: contentId, diff --git a/components/casting/CastingMiniPlayer.tsx b/components/casting/CastingMiniPlayer.tsx index 7797d4b1..573f0431 100644 --- a/components/casting/CastingMiniPlayer.tsx +++ b/components/casting/CastingMiniPlayer.tsx @@ -45,9 +45,9 @@ export const CastingMiniPlayer: React.FC = () => { return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined; }, [mediaStatus?.mediaInfo?.customData]); - // Trickplay support - pass currentItem as BaseItemDto or empty object + // Trickplay support - pass currentItem as BaseItemDto or null const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( - currentItem || ({} as BaseItemDto), + currentItem || null, ); const [trickplayTime, setTrickplayTime] = useState({ hours: 0, @@ -121,7 +121,7 @@ export const CastingMiniPlayer: React.FC = () => { } }, [progress, sliderProgress]); - // For episodes, use season poster; for other content, use item poster + // For episodes, use series poster; for other content, use item poster const posterUrl = useMemo(() => { if (!api?.basePath || !currentItem) return null; @@ -131,8 +131,8 @@ export const CastingMiniPlayer: React.FC = () => { currentItem.ParentIndexNumber !== undefined && currentItem.SeasonId ) { - // Build season poster URL using SeriesId and image tag for cache validation - const imageTag = currentItem.ImageTags?.Primary || ""; + // Build series poster URL using SeriesId and series-level image tag + const imageTag = currentItem.SeriesPrimaryImageTag || ""; const tagParam = imageTag ? `&tag=${imageTag}` : ""; return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`; } diff --git a/components/chromecast/ChromecastConnectionMenu.tsx b/components/chromecast/ChromecastConnectionMenu.tsx index 05c1c2ee..cd5386cd 100644 --- a/components/chromecast/ChromecastConnectionMenu.tsx +++ b/components/chromecast/ChromecastConnectionMenu.tsx @@ -108,7 +108,7 @@ export const ChromecastConnectionMenu: React.FC< try { if (castSession) { - await castSession.setVolume(value / 100); + await castSession.setVolume(rounded / 100); } } catch (error) { console.error("[Connection Menu] Volume error:", error); @@ -262,7 +262,9 @@ export const ChromecastConnectionMenu: React.FC< onValueChange={async (value) => { volumeValue.value = value; handleVolumeChange(value); - if (isMuted) { + // Unmute when adjusting volume - use ref to avoid + // stale closure and prevent repeated async calls + if (isMutedRef.current) { isMutedRef.current = false; setIsMuted(false); try { diff --git a/components/chromecast/ChromecastDeviceSheet.tsx b/components/chromecast/ChromecastDeviceSheet.tsx index 99455c5e..de27e251 100644 --- a/components/chromecast/ChromecastDeviceSheet.tsx +++ b/components/chromecast/ChromecastDeviceSheet.tsx @@ -45,7 +45,9 @@ export const ChromecastDeviceSheet: React.FC = ({ const lastSetVolume = useRef(Math.round(volume * 100)); // Sync volume slider with prop changes (updates from physical buttons) + // Skip updates while user is actively sliding to avoid overwriting drag useEffect(() => { + if (isSliding.current) return; volumeValue.value = volume * 100; setDisplayVolume(Math.round(volume * 100)); }, [volume, volumeValue]); @@ -275,13 +277,9 @@ export const ChromecastDeviceSheet: React.FC = ({ minimumTrackTintColor: isMuted ? "#666" : "#a855f7", bubbleBackgroundColor: "#a855f7", }} - onSlidingStart={() => { + onSlidingStart={async () => { isSliding.current = true; - }} - onValueChange={async (value) => { - volumeValue.value = value; - handleVolumeChange(value); - // Unmute when adjusting volume + // Auto-unmute when user starts adjusting volume if (isMuted && castSession) { setIsMuted(false); try { @@ -292,6 +290,10 @@ export const ChromecastDeviceSheet: React.FC = ({ } } }} + onValueChange={(value) => { + volumeValue.value = value; + handleVolumeChange(value); + }} onSlidingComplete={(value) => { isSliding.current = false; lastSetVolume.current = Math.round(value); diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts index d7ed2791..715e7d5d 100644 --- a/hooks/useCasting.ts +++ b/hooks/useCasting.ts @@ -120,8 +120,13 @@ export const useCasting = (item: BaseItemDto | null) => { const playStateApi = getPlaystateApi(api); // Report playback start when media begins (only once per item) + // Don't require progress > 0 — playback can legitimately start at position 0 const currentState = stateRef.current; - if (hasReportedStartRef.current !== item.Id && currentState.progress > 0) { + const isPlaybackActive = + currentState.isPlaying || + mediaStatus?.playerState === "playing" || + currentState.progress > 0; + if (hasReportedStartRef.current !== item.Id && isPlaybackActive) { // Set synchronously before async call to prevent race condition duplicates hasReportedStartRef.current = item.Id || null; @@ -366,8 +371,11 @@ export const useCasting = (item: BaseItemDto | null) => { duration: state.duration, volume: state.volume, - // Availability - isChromecastAvailable: true, // Always available via react-native-google-cast + // Availability - derived from actual cast state + isChromecastAvailable: + castState === CastState.CONNECTED || + castState === CastState.CONNECTING || + castState === CastState.NOT_CONNECTED, // Raw clients (for advanced operations) remoteMediaClient: client, diff --git a/hooks/useTrickplay.ts b/hooks/useTrickplay.ts index 5ebae53a..8ec94236 100644 --- a/hooks/useTrickplay.ts +++ b/hooks/useTrickplay.ts @@ -17,20 +17,24 @@ interface TrickplayUrl { } /** Hook to handle trickplay logic for a given item. */ -export const useTrickplay = (item: BaseItemDto) => { +export const useTrickplay = (item: BaseItemDto | null) => { const { getDownloadedItemById } = useDownload(); const [trickPlayUrl, setTrickPlayUrl] = useState(null); const lastCalculationTime = useRef(0); const throttleDelay = 200; const isOffline = useGlobalSearchParams().offline === "true"; - const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]); + const trickplayInfo = useMemo( + () => (item ? getTrickplayInfo(item) : null), + [item], + ); /** Generates the trickplay URL for the given item and sheet index. * We change between offline and online trickplay URLs depending on the state of the app. */ const getTrickplayUrl = useCallback( (item: BaseItemDto, sheetIndex: number) => { + if (!item.Id) return null; // If we are offline, we can use the downloaded item's trickplay data path - const downloadedItem = getDownloadedItemById(item.Id!); + const downloadedItem = getDownloadedItemById(item.Id); if (isOffline && downloadedItem?.trickPlayData?.path) { return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`; } @@ -45,7 +49,7 @@ export const useTrickplay = (item: BaseItemDto) => { const now = Date.now(); if ( !trickplayInfo || - !item.Id || + !item?.Id || now - lastCalculationTime.current < throttleDelay ) return; @@ -62,7 +66,7 @@ export const useTrickplay = (item: BaseItemDto) => { /** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */ const prefetchAllTrickplayImages = useCallback(async () => { - if (!trickplayInfo || !item.Id) return; + if (!trickplayInfo || !item?.Id) return; const maxConcurrent = 4; const total = trickplayInfo.totalImageSheets; const urls: string[] = [];