From 4ad07d22bd0821be65e3976fd5d6b402a22354d8 Mon Sep 17 00:00:00 2001 From: Uruk Date: Thu, 22 Jan 2026 18:57:56 +0100 Subject: [PATCH] feat: Enhance Chromecast functionality and UI improvements - Implemented a retry mechanism for Chromecast device discovery with a maximum of 3 attempts. - Added logging for discovered devices to aid in debugging. - Updated Chromecast button interactions to streamline navigation to the casting player. - Changed the color scheme for Chromecast components to a consistent purple theme. - Modified the ChromecastDeviceSheet to sync volume slider with prop changes. - Improved the ChromecastSettingsMenu to conditionally render audio and subtitle tracks based on availability. - Updated translations for the casting player to include new strings for better user experience. --- app/(auth)/casting-player.tsx | 1437 ++++++++++++----- components/Chromecast.tsx | 97 +- components/PlayButton.tsx | 2 +- components/casting/CastingMiniPlayer.tsx | 2 +- .../chromecast/ChromecastDeviceSheet.tsx | 22 +- .../chromecast/ChromecastEpisodeList.tsx | 4 +- .../chromecast/ChromecastSettingsMenu.tsx | 29 +- components/item/ItemPeopleSections.tsx | 2 +- translations/en.json | 15 + 9 files changed, 1135 insertions(+), 475 deletions(-) diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 21b78217..7259dfa1 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -5,13 +5,22 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; +import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { Image } from "expo-image"; -import { router } from "expo-router"; +import { router, Stack } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { + CastState, + MediaPlayerState, + useCastDevice, + useCastState, + useMediaStatus, + useRemoteMediaClient, +} from "react-native-google-cast"; import Animated, { runOnJS, useAnimatedStyle, @@ -25,30 +34,88 @@ import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettin import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments"; import { Text } from "@/components/common/Text"; import { useCasting } from "@/hooks/useCasting"; -import { apiAtom } from "@/providers/JellyfinProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; import { calculateEndingTime, formatTime, + getConnectionQuality, getPosterUrl, - getProtocolIcon, - getProtocolName, shouldShowNextEpisodeCountdown, truncateTitle, } from "@/utils/casting/helpers"; +import type { CastProtocol } from "@/utils/casting/types"; export default function CastingPlayerScreen() { const insets = useSafeAreaInsets(); const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + const { t } = useTranslation(); + // Get raw Chromecast state directly - same as old implementation + const castState = useCastState(); + const mediaStatus = useMediaStatus(); + const castDevice = useCastDevice(); + useRemoteMediaClient(); // Keep connection active + + // Live progress tracking - update every second + const [liveProgress, setLiveProgress] = useState(0); + + useEffect(() => { + // Initialize with actual position + if (mediaStatus?.streamPosition) { + setLiveProgress(mediaStatus.streamPosition); + } + + // Update every second when playing + const interval = setInterval(() => { + if ( + mediaStatus?.playerState === MediaPlayerState.PLAYING && + mediaStatus?.streamPosition !== undefined + ) { + setLiveProgress((prev) => prev + 1); + } else if (mediaStatus?.streamPosition !== undefined) { + // Sync with actual position when paused/buffering + setLiveProgress(mediaStatus.streamPosition); + } + }, 1000); + + return () => clearInterval(interval); + }, [mediaStatus?.playerState, mediaStatus?.streamPosition]); + + // Extract item from customData, or create a minimal item from mediaInfo + const currentItem = useMemo(() => { + const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null; + + // If we have full item data in customData, use it + if (customData) return customData; + + // Otherwise, create a minimal item from available mediaInfo + if (mediaStatus?.mediaInfo) { + const { contentId, metadata } = mediaStatus.mediaInfo; + return { + Id: contentId, + Name: metadata?.title || "Unknown", + Type: "Movie", // Default type + ServerId: "", + } as BaseItemDto; + } + + return null; + }, [mediaStatus?.mediaInfo]); + + // Derive state from raw Chromecast hooks + const protocol: CastProtocol = "chromecast"; + 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; + + // Only use casting controls if we have a current item to avoid "No session" errors + const castingControls = useCasting(currentItem); const { - isConnected, - protocol, - currentItem, - currentDevice, - progress, - duration, - isPlaying, - isBuffering, togglePlayPause, skipForward, skipBackward, @@ -56,30 +123,149 @@ export default function CastingPlayerScreen() { setVolume, volume, remoteMediaClient, - seek, - } = useCasting(null); + } = currentItem + ? castingControls + : { + togglePlayPause: async () => {}, + skipForward: async () => {}, + skipBackward: async () => {}, + stop: async () => {}, + setVolume: async () => {}, + volume: 1, + remoteMediaClient: null, + }; // Modal states const [showEpisodeList, setShowEpisodeList] = useState(false); const [showDeviceSheet, setShowDeviceSheet] = useState(false); const [showSettings, setShowSettings] = useState(false); + const [showTechnicalInfo, setShowTechnicalInfo] = useState(false); const [episodes, setEpisodes] = useState([]); const [nextEpisode, setNextEpisode] = useState(null); + const [seasonData, setSeasonData] = useState(null); + + // Track selection states + const [selectedAudioTrackIndex, setSelectedAudioTrackIndex] = useState< + number | null + >(null); + const [selectedSubtitleTrackIndex, setSelectedSubtitleTrackIndex] = useState< + number | null + >(null); + const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0); + + // Fetch season data for season poster + useEffect(() => { + if ( + currentItem?.Type !== "Episode" || + !currentItem.SeasonId || + !api || + !user?.Id + ) + return; + + const fetchSeasonData = async () => { + try { + console.log( + `[Casting Player] Fetching season data for SeasonId: ${currentItem.SeasonId}`, + ); + const userLibraryApi = getUserLibraryApi(api); + const response = await userLibraryApi.getItem({ + itemId: currentItem.SeasonId!, + userId: user.Id!, + }); + console.log("[Casting Player] Season data fetched:", { + Id: response.data.Id, + Name: response.data.Name, + ImageTags: response.data.ImageTags, + ParentPrimaryImageItemId: response.data.ParentPrimaryImageItemId, + }); + setSeasonData(response.data); + } catch (error) { + console.error("[Casting Player] Failed to fetch season data:", error); + setSeasonData(null); + } + }; + + fetchSeasonData(); + }, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]); const availableAudioTracks = useMemo(() => { - // TODO: Parse from mediaInfo.mediaTracks or currentItem.MediaStreams - return []; - }, []); + if (!currentItem?.MediaStreams) return []; + + return currentItem.MediaStreams.filter( + (stream) => stream.Type === "Audio", + ).map((stream) => ({ + index: stream.Index ?? 0, + language: stream.Language || "Unknown", + displayTitle: + stream.DisplayTitle || + `${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(), + codec: stream.Codec || "Unknown", + channels: stream.Channels, + bitrate: stream.BitRate, + })); + }, [currentItem?.MediaStreams]); const availableSubtitleTracks = useMemo(() => { - // TODO: Parse from mediaInfo.mediaTracks or currentItem.MediaStreams - return []; - }, []); + if (!currentItem?.MediaStreams) return []; + + return currentItem.MediaStreams.filter( + (stream) => stream.Type === "Subtitle", + ).map((stream) => ({ + index: stream.Index ?? 0, + language: stream.Language || "Unknown", + displayTitle: + stream.DisplayTitle || + `${stream.Language || "Unknown"}${stream.IsForced ? " (Forced)" : ""}${stream.Title ? ` - ${stream.Title}` : ""}`, + codec: stream.Codec || "Unknown", + isForced: stream.IsForced || false, + isExternal: stream.IsExternal || false, + })); + }, [currentItem?.MediaStreams]); const availableMediaSources = useMemo(() => { - // TODO: Get from currentItem.MediaSources - return []; - }, []); + // Get the original source bitrate + const originalBitrate = + currentItem?.MediaSources?.[0]?.Bitrate || + currentItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate || + 20000000; // Default to 20Mbps if unknown + + // Generate bitrate variants + const variants = [ + { + id: `${currentItem?.Id}-max`, + name: "Max", + bitrate: originalBitrate, + container: currentItem?.MediaSources?.[0]?.Container || "mp4", + }, + { + id: `${currentItem?.Id}-8mbps`, + name: "8 Mb/s", + bitrate: 8000000, + container: currentItem?.MediaSources?.[0]?.Container || "mp4", + }, + { + id: `${currentItem?.Id}-4mbps`, + name: "4 Mb/s", + bitrate: 4000000, + container: currentItem?.MediaSources?.[0]?.Container || "mp4", + }, + { + id: `${currentItem?.Id}-2mbps`, + name: "2 Mb/s", + bitrate: 2000000, + container: currentItem?.MediaSources?.[0]?.Container || "mp4", + }, + { + id: `${currentItem?.Id}-1mbps`, + name: "1 Mb/s", + bitrate: 1000000, + container: currentItem?.MediaSources?.[0]?.Container || "mp4", + }, + ]; + + return variants; + }, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]); // Fetch episodes for TV shows useEffect(() => { @@ -121,75 +307,169 @@ export default function CastingPlayerScreen() { api, ]); - // Segment detection (skip intro/credits) + // 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 + console.log("[Casting Player] Auto-navigating to player for new cast"); + router.replace("/casting-player" as any); + } + }, [mediaStatus?.currentItemId, currentItem, router]); + + // Segment detection (skip intro/credits) - use progress in seconds for accurate detection const { currentSegment, skipIntro, skipCredits, skipSegment } = - useChromecastSegments(currentItem, progress, false); + useChromecastSegments(currentItem, progress * 1000, false); // Swipe down to dismiss gesture const translateY = useSharedValue(0); const context = useSharedValue({ y: 0 }); + // Progress bar swipe gesture + const progressGestureContext = useSharedValue({ startValue: 0 }); + const isSeeking = useSharedValue(false); + const dismissModal = useCallback(() => { + // Reset animation before dismissing to prevent black screen + translateY.value = 0; if (router.canGoBack()) { router.back(); } - }, []); + }, [translateY]); const panGesture = Gesture.Pan() .onStart(() => { context.value = { y: translateY.value }; }) .onUpdate((event) => { + // Only allow downward swipes from top of screen if (event.translationY > 0) { - translateY.value = event.translationY; + translateY.value = context.value.y + event.translationY; } }) .onEnd((event) => { - if (event.translationY > 100) { - translateY.value = withSpring(500, {}, () => { - runOnJS(dismissModal)(); - }); + // Dismiss if swiped down more than 150px or fast swipe + if (event.translationY > 150 || event.velocityY > 600) { + // Animate down and dismiss + translateY.value = withSpring( + 1000, + { + damping: 20, + stiffness: 90, + }, + () => { + runOnJS(dismissModal)(); + }, + ); } else { + // Spring back to position translateY.value = withSpring(0); } }); + // Progress bar pan gesture for seeking + const progressPanGesture = Gesture.Pan() + .onBegin(() => { + isSeeking.value = true; + progressGestureContext.value = { startValue: liveProgress }; + }) + .onUpdate((event) => { + if (!duration) return; + // Calculate seek delta based on screen width (more sensitive) + const deltaSeconds = event.translationX / 5; // Adjust sensitivity + const newPosition = Math.max( + 0, + Math.min( + duration, + progressGestureContext.value.startValue + deltaSeconds, + ), + ); + // Update live progress for immediate UI feedback + setLiveProgress(newPosition); + }) + .onEnd(() => { + isSeeking.value = false; + // Seek to final position + if (remoteMediaClient) { + const finalPosition = Math.max(0, Math.min(duration, liveProgress)); + remoteMediaClient.seek({ position: finalPosition }).catch((error) => { + console.error("[Casting Player] Seek error:", error); + }); + } + }); + const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); // Memoize expensive calculations (before early return) - const posterUrl = useMemo( - () => - getPosterUrl( - api?.basePath, - currentItem?.Id, - currentItem?.ImageTags?.Primary, - 300, - 450, - ), - [api?.basePath, currentItem?.Id, currentItem?.ImageTags?.Primary], - ); + const posterUrl = useMemo(() => { + if (!api?.basePath || !currentItem?.Id) return null; + + // For episodes, use SEASON poster instead of episode poster + if (currentItem.Type === "Episode" && seasonData?.Id) { + // Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags + const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id; + const seasonImageTag = seasonData.ImageTags?.Primary; + console.log( + `[Casting Player] Using season poster for ${seasonData.Name}`, + { imageItemId, seasonImageTag }, + ); + return seasonImageTag + ? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=390&fillWidth=260&quality=96&tag=${seasonImageTag}` + : `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=390&fillWidth=260&quality=96`; + } + + // Fallback to item poster for non-episodes or if season data not loaded + console.log( + `[Casting Player] Using fallback poster for ${currentItem.Name}`, + { + Type: currentItem.Type, + hasSeasonData: !!seasonData?.Id, + }, + ); + return getPosterUrl( + api.basePath, + currentItem.Id, + currentItem.ImageTags?.Primary, + 260, + 390, + ); + }, [ + api?.basePath, + currentItem?.Id, + currentItem?.Type, + seasonData?.Id, + seasonData?.ImageTags?.Primary, + currentItem?.ImageTags?.Primary, + ]); const progressPercent = useMemo( () => (duration > 0 ? (progress / duration) * 100 : 0), [progress, duration], ); - const protocolColor = useMemo( - () => (protocol === "chromecast" ? "#F9AB00" : "#666"), // Google yellow - [protocol], - ); + const protocolColor = "#a855f7"; // Streamyfin purple - const protocolIcon = useMemo( - () => (protocol ? getProtocolIcon(protocol) : ("tv" as const)), - [protocol], - ); + const connectionQuality = useMemo(() => { + const bitrate = availableMediaSources[0]?.bitrate; + return getConnectionQuality(bitrate); + }, [availableMediaSources]); - const protocolName = useMemo( - () => (protocol ? getProtocolName(protocol) : "Unknown"), - [protocol], - ); + // Get quality indicator color + const qualityColor = useMemo(() => { + switch (connectionQuality) { + case "excellent": + return "#22c55e"; // green + case "good": + return "#eab308"; // yellow + case "fair": + return "#f59e0b"; // orange + case "poor": + return "#ef4444"; // red + default: + return protocolColor; + } + }, [connectionQuality, protocolColor]); const showNextEpisode = useMemo(() => { if (currentItem?.Type !== "Episode" || !nextEpisode) return false; @@ -197,49 +477,95 @@ export default function CastingPlayerScreen() { return shouldShowNextEpisodeCountdown(remaining, true, 30); }, [currentItem?.Type, nextEpisode, duration, progress]); - // Redirect if not connected + // Redirect if not connected - check CastState like old implementation useEffect(() => { - if (!isConnected || !currentItem || !protocol) { - if (router.canGoBack()) { - router.back(); - } else { - router.replace("/(auth)/(tabs)/(home)/"); - } - } - }, [isConnected, currentItem, protocol]); + // Redirect immediately when disconnected or no devices + if ( + castState === CastState.NOT_CONNECTED || + castState === CastState.NO_DEVICES_AVAILABLE + ) { + // Use setTimeout to avoid state update during render + const timer = setTimeout(() => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(auth)/(tabs)/(home)/"); + } + }, 100); - // Don't render if not connected - if (!isConnected || !currentItem || !protocol) { + return () => clearTimeout(timer); + } + }, [castState]); + + // Also redirect if mediaStatus disappears (media ended or stopped) + useEffect(() => { + if (castState === CastState.CONNECTED && !mediaStatus) { + const timer = setTimeout(() => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(auth)/(tabs)/(home)/"); + } + }, 500); // Small delay to allow for media transitions + + return () => clearTimeout(timer); + } + }, [castState, mediaStatus]); + + // Show loading while connecting + if (castState === CastState.CONNECTING) { + return ( + + + + Connecting to Chromecast... + + + ); + } + + // Don't render if not connected or no media playing + if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) { return null; } return ( - - - + + + - {/* Header */} + {/* Header - Fixed at top */} - {protocolName} + {currentDevice || "Unknown Device"} + {/* Connection quality indicator with color */} + - {/* Title and episode info */} - - - {truncateTitle(currentItem.Name || "Unknown", 50)} - - {currentItem.SeriesName && ( + {/* Scrollable content area */} + + {/* Title */} + - {currentItem.SeriesName} - {currentItem.ParentIndexNumber && - currentItem.IndexNumber && - ` • S${currentItem.ParentIndexNumber}:E${currentItem.IndexNumber}`} + {truncateTitle(currentItem.Name || "Unknown", 50)} - )} - + - {/* Poster with buffering overlay */} - - - {posterUrl ? ( - - ) : ( - - - - )} - - {/* Buffering overlay */} - {isBuffering && ( - - + {/* Grey episode/season info between title and poster */} + {currentItem.Type === "Episode" && + currentItem.ParentIndexNumber !== undefined && + currentItem.IndexNumber !== undefined && ( + - Buffering... + {t("casting_player.season_episode_format", { + season: currentItem.ParentIndexNumber, + episode: currentItem.IndexNumber, + })} )} - - - {/* Device info */} - - - - {currentDevice?.name || protocolName} - - - - {/* Progress slider */} - - { - // Calculate tap position and seek - const { locationX } = e.nativeEvent; - // Get width from event target - const width = ( - e.currentTarget as unknown as { offsetWidth: number } - ).offsetWidth; - if (width > 0) { - const percent = locationX / width; - const newPosition = duration * percent; - seek(newPosition); - } + {/* Poster with buffering overlay - reduced size */} + - + {posterUrl ? ( + + ) : ( + + + + )} + + {/* Skip intro/credits bar at bottom of poster */} + {currentSegment && ( + { + if (!remoteMediaClient) return; + try { + const seekFn = async (positionMs: number) => { + if ( + mediaStatus?.playerState === + MediaPlayerState.PLAYING || + mediaStatus?.playerState === MediaPlayerState.PAUSED + ) { + await remoteMediaClient.seek({ + position: positionMs / 1000, + }); + } + }; + if (currentSegment.type === "intro") { + await skipIntro(seekFn); + } else if (currentSegment.type === "credits") { + await skipCredits(seekFn); + } else { + await skipSegment(seekFn); + } + } catch (error) { + console.error("[Casting Player] Skip error:", error); + } + }} + style={{ + position: "absolute", + bottom: 0, + left: 0, + right: 0, + backgroundColor: protocolColor, + paddingVertical: 12, + paddingHorizontal: 16, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + }} + > + + + {currentSegment.type === "intro" + ? t("player.skip_intro") + : currentSegment.type === "credits" + ? t("player.skip_outro") + : `Skip ${currentSegment.type}`} + + + )} + + {/* Buffering overlay */} + {isBuffering && ( + + + + {t("casting_player.buffering")} + + + )} - - - - {/* Time display */} - - - {formatTime(progress)} - - - Ending at {calculateEndingTime(progress, duration)} - - - {formatTime(duration)} - - - - {/* Segment skip button (intro/credits) */} - {currentSegment && ( - - { - if (!remoteMediaClient) return; - // Create seek function wrapper for remote media client - const seekFn = (positionMs: number) => - remoteMediaClient.seek({ position: positionMs / 1000 }); - - if (currentSegment.type === "intro") { - skipIntro(seekFn); - } else if (currentSegment.type === "credits") { - skipCredits(seekFn); - } else { - skipSegment(seekFn); - } - }} - style={{ - backgroundColor: protocolColor, - paddingHorizontal: 24, - paddingVertical: 12, - borderRadius: 8, - flexDirection: "row", - alignItems: "center", - gap: 8, - }} - > - - - Skip{" "} - {currentSegment.type.charAt(0).toUpperCase() + - currentSegment.type.slice(1)} - - - )} - {/* Next episode countdown */} - {showNextEpisode && nextEpisode && ( - + {/* 4-button control row for episodes */} + {currentItem.Type === "Episode" && episodes.length > 0 && ( - - - - Next: {nextEpisode.Name} - - - Starting in {Math.ceil((duration - progress) / 1000)}s - - + {/* Episodes button */} { - setNextEpisode(null); // Cancel auto-play + onPress={() => setShowEpisodeList(true)} + style={{ + flex: 1, + backgroundColor: "#1a1a1a", + padding: 12, + borderRadius: 12, + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: 6, }} - style={{ marginLeft: 8 }} > - + + + {t("casting_player.episodes")} + + + + {/* Favorite button */} + { + if (!api || !user?.Id || !currentItem.Id) return; + try { + const newIsFavorite = !( + currentItem.UserData?.IsFavorite ?? false + ); + const path = `/Users/${user.Id}/FavoriteItems/${currentItem.Id}`; + + if (newIsFavorite) { + await api.post(path, {}, {}); + } else { + await api.delete(path, {}); + } + + // Update local state + if (currentItem.UserData) { + currentItem.UserData.IsFavorite = newIsFavorite; + } + } catch (error) { + console.error("Failed to toggle favorite:", error); + } + }} + style={{ + flex: 1, + backgroundColor: "#1a1a1a", + padding: 12, + borderRadius: 12, + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: 6, + }} + > + + + {t("casting_player.favorite")} + + + + {/* Previous episode button */} + { + const currentIndex = episodes.findIndex( + (ep) => ep.Id === currentItem.Id, + ); + if (currentIndex > 0 && remoteMediaClient) { + const previousEp = episodes[currentIndex - 1]; + console.log("Previous episode:", previousEp.Name); + } + }} + disabled={ + episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0 + } + style={{ + flex: 1, + backgroundColor: "#1a1a1a", + padding: 12, + borderRadius: 12, + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: 6, + opacity: + episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0 + ? 0.4 + : 1, + }} + > + + + {t("casting_player.previous")} + + + + {/* Next episode button */} + { + if (nextEpisode && remoteMediaClient) { + console.log("Next episode:", nextEpisode.Name); + } + }} + disabled={!nextEpisode} + style={{ + flex: 1, + backgroundColor: "#1a1a1a", + padding: 12, + borderRadius: 12, + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: 6, + opacity: nextEpisode ? 1 : 0.4, + }} + > + + + {t("casting_player.next")} + + )} + + {/* Progress slider - interactive with pan gesture and tap */} + + + { + if (!remoteMediaClient || !duration) return; + // Get the layout to calculate percentage + e.currentTarget.measure( + (_x, _y, width, _height, pageX, _pageY) => { + const touchX = e.nativeEvent.pageX - pageX; + const percentage = Math.max( + 0, + Math.min(1, touchX / width), + ); + const newPosition = percentage * duration; + remoteMediaClient + .seek({ position: newPosition }) + .catch(console.error); + }, + ); + }} + > + + + + + - )} - {/* Playback controls */} - - {/* Rewind 10s */} - skipBackward(10)} style={{ padding: 16 }}> - - - - {/* Play/Pause */} - - - + + {formatTime(progress * 1000)} + + + Ending at{" "} + {calculateEndingTime(progress * 1000, duration * 1000)} + + + {formatTime(duration * 1000)} + + - {/* Forward 10s */} - skipForward(10)} style={{ padding: 16 }}> - - - + {/* Next episode countdown */} + {showNextEpisode && nextEpisode && ( + + + + + + {t("player.next_episode")}: {nextEpisode.Name} + + + Starting in {Math.ceil((duration - progress) / 1000)}s + + + { + setNextEpisode(null); // Cancel auto-play + }} + style={{ marginLeft: 8 }} + > + + + + + )} - {/* Stop casting button */} - + {/* Rewind (use settings) */} + skipBackward(settings?.rewindSkipTime ?? 10)} + style={{ + position: "relative", + justifyContent: "center", + alignItems: "center", + }} + > + + {settings?.rewindSkipTime && ( + + {settings.rewindSkipTime} + + )} + + + {/* Play/Pause */} + + + + + {/* Forward (use settings) */} + skipForward(settings?.forwardSkipTime ?? 10)} + style={{ + position: "relative", + justifyContent: "center", + alignItems: "center", + }} + > + + {settings?.forwardSkipTime && ( + + {settings.forwardSkipTime} + + )} + + + + + {/* Fixed End Playback button at bottom */} + - - - Stop Casting - - - - {/* Episode list button (for TV shows) */} - {currentItem.Type === "Episode" && ( setShowEpisodeList(true)} + onPress={async () => { + try { + await stop(); + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(auth)/(tabs)/(home)/"); + } + } catch (error) { + console.error("[Casting Player] Error stopping:", error); + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(auth)/(tabs)/(home)/"); + } + } + }} style={{ - backgroundColor: "#1a1a1a", - padding: 16, - borderRadius: 12, flexDirection: "row", justifyContent: "center", alignItems: "center", gap: 8, - marginBottom: 24, + paddingVertical: 16, + paddingHorizontal: 24, + borderRadius: 12, + backgroundColor: "#1a1a1a", + borderWidth: 2, + borderColor: "#FF3B30", }} > - - - Episodes + + + {t("casting_player.end_playback")} - )} - + - {/* Modals */} - setShowDeviceSheet(false)} - device={ - currentDevice && protocol === "chromecast" - ? ({ - deviceId: currentDevice.id, - friendlyName: currentDevice.name, - } as any) - : null - } - onDisconnect={stop} - volume={volume} - onVolumeChange={async (vol) => setVolume(vol)} - /> + {/* Modals */} + setShowDeviceSheet(false)} + device={ + currentDevice && protocol === "chromecast" && castDevice + ? ({ + deviceId: castDevice.deviceId, + friendlyName: currentDevice, + } as any) + : null + } + onDisconnect={async () => { + try { + await stop(); + setShowDeviceSheet(false); + // Close player immediately after stopping + setTimeout(() => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(auth)/(tabs)/(home)/"); + } + }, 100); + } catch (error) { + console.error("[Casting Player] Error stopping:", error); + } + }} + volume={volume} + onVolumeChange={async (vol) => { + setVolume(vol); + }} + showTechnicalInfo={showTechnicalInfo} + /> - setShowEpisodeList(false)} - currentItem={currentItem} - episodes={episodes} - onSelectEpisode={(episode) => { - // TODO: Load new episode - requires casting new media - console.log("Selected episode:", episode.Name); - setShowEpisodeList(false); - }} - /> + setShowEpisodeList(false)} + currentItem={currentItem} + episodes={episodes} + onSelectEpisode={(episode) => { + // TODO: Load new episode - requires casting new media + console.log("Selected episode:", episode.Name); + setShowEpisodeList(false); + }} + /> - setShowSettings(false)} - item={currentItem} - mediaSources={availableMediaSources} - selectedMediaSource={null} - onMediaSourceChange={(source) => { - // TODO: Requires reloading media with new source URL - console.log("Changed media source:", source); - }} - audioTracks={availableAudioTracks} - selectedAudioTrack={null} - onAudioTrackChange={(track) => { - // Set active tracks using RemoteMediaClient - remoteMediaClient - ?.setActiveTrackIds([track.index]) - .catch(console.error); - }} - subtitleTracks={availableSubtitleTracks} - selectedSubtitleTrack={null} - onSubtitleTrackChange={(track) => { - if (track) { + setShowSettings(false)} + item={currentItem} + mediaSources={availableMediaSources} + selectedMediaSource={availableMediaSources[0] || null} + onMediaSourceChange={(source) => { + // TODO: Requires reloading media with new source URL + console.log("Changed media source:", source); + }} + audioTracks={availableAudioTracks} + selectedAudioTrack={ + selectedAudioTrackIndex !== null + ? availableAudioTracks.find( + (t) => t.index === selectedAudioTrackIndex, + ) || null + : availableAudioTracks[0] || null + } + onAudioTrackChange={(track) => { + setSelectedAudioTrackIndex(track.index); + // Set active tracks using RemoteMediaClient remoteMediaClient ?.setActiveTrackIds([track.index]) .catch(console.error); - } else { - // Disable subtitles - remoteMediaClient?.setActiveTrackIds([]).catch(console.error); + }} + subtitleTracks={availableSubtitleTracks} + selectedSubtitleTrack={ + selectedSubtitleTrackIndex !== null + ? availableSubtitleTracks.find( + (t) => t.index === selectedSubtitleTrackIndex, + ) || null + : null } - }} - playbackSpeed={1.0} - onPlaybackSpeedChange={(speed) => { - remoteMediaClient?.setPlaybackRate(speed).catch(console.error); - }} - showTechnicalInfo={false} - onToggleTechnicalInfo={() => { - // TODO: Show/hide technical info section - }} - /> - - + onSubtitleTrackChange={(track) => { + setSelectedSubtitleTrackIndex(track?.index ?? null); + if (track) { + remoteMediaClient + ?.setActiveTrackIds([track.index]) + .catch(console.error); + } else { + // Disable subtitles + remoteMediaClient?.setActiveTrackIds([]).catch(console.error); + } + }} + playbackSpeed={currentPlaybackSpeed} + onPlaybackSpeedChange={(speed) => { + setCurrentPlaybackSpeed(speed); + remoteMediaClient?.setPlaybackRate(speed).catch(console.error); + }} + showTechnicalInfo={showTechnicalInfo} + onToggleTechnicalInfo={() => { + setShowTechnicalInfo(!showTechnicalInfo); + }} + /> + + + ); } diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index 855e7dd8..372d1a22 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -23,27 +23,76 @@ export function Chromecast({ background = "transparent", ...props }) { - const client = useRemoteMediaClient(); - const castDevice = useCastDevice(); + const _client = useRemoteMediaClient(); + const _castDevice = useCastDevice(); const devices = useDevices(); - const sessionManager = GoogleCast.getSessionManager(); + const _sessionManager = GoogleCast.getSessionManager(); const discoveryManager = GoogleCast.getDiscoveryManager(); const mediaStatus = useMediaStatus(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const lastReportedProgressRef = useRef(0); + const discoveryAttempts = useRef(0); + const maxDiscoveryAttempts = 3; + const hasLoggedDevices = useRef(false); + // Enhanced discovery with retry mechanism - runs once on mount useEffect(() => { - (async () => { + let isSubscribed = true; + let retryTimeout: NodeJS.Timeout; + + const startDiscoveryWithRetry = async () => { if (!discoveryManager) { - console.warn("DiscoveryManager is not initialized"); return; } - await discoveryManager.startDiscovery(); - })(); - }, [client, devices, castDevice, sessionManager, discoveryManager]); + try { + // Stop any existing discovery first + try { + await discoveryManager.stopDiscovery(); + } catch (_e) { + // Ignore errors when stopping + } + + // Start fresh discovery + await discoveryManager.startDiscovery(); + discoveryAttempts.current = 0; // Reset on success + } catch (error) { + console.error("[Chromecast Discovery] Failed:", error); + + // Retry on error + if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) { + discoveryAttempts.current++; + retryTimeout = setTimeout(() => { + if (isSubscribed) { + startDiscoveryWithRetry(); + } + }, 2000); + } + } + }; + + startDiscoveryWithRetry(); + + return () => { + isSubscribed = false; + if (retryTimeout) { + clearTimeout(retryTimeout); + } + }; + }, [discoveryManager]); // Only re-run if discoveryManager changes + + // Log device changes for debugging - only once per session + useEffect(() => { + if (devices.length > 0 && !hasLoggedDevices.current) { + console.log( + "[Chromecast] Found device(s):", + devices.map((d) => d.friendlyName || d.deviceId).join(", "), + ); + hasLoggedDevices.current = true; + } + }, [devices]); // Report video progress to Jellyfin server useEffect(() => { @@ -104,13 +153,11 @@ export function Chromecast({ { - console.log("Chromecast button tapped (iOS)", { - hasMediaStatus: !!mediaStatus, - currentItemId: mediaStatus?.currentItemId, - castDevice: castDevice?.friendlyName, - }); - if (mediaStatus?.currentItemId) router.push("/casting-player"); - else CastContext.showCastDialog(); + if (mediaStatus?.currentItemId) { + router.push("/casting-player"); + } else { + CastContext.showCastDialog(); + } }} {...props} > @@ -127,14 +174,8 @@ export function Chromecast({ className='mr-2' background={false} onPress={() => { - console.log("Chromecast button tapped (Android transparent)", { - hasMediaStatus: !!mediaStatus, - currentItemId: mediaStatus?.currentItemId, - castDevice: castDevice?.friendlyName, - }); if (mediaStatus?.currentItemId) { - console.log("Navigating to: /(auth)/casting-player"); - router.push("/(auth)/casting-player"); + router.replace("/casting-player" as any); } else { CastContext.showCastDialog(); } @@ -150,13 +191,11 @@ export function Chromecast({ { - console.log("Chromecast button tapped (Android)", { - hasMediaStatus: !!mediaStatus, - currentItemId: mediaStatus?.currentItemId, - castDevice: castDevice?.friendlyName, - }); - if (mediaStatus?.currentItemId) router.push("/casting-player"); - else CastContext.showCastDialog(); + if (mediaStatus?.currentItemId) { + router.push("/casting-player"); + } else { + CastContext.showCastDialog(); + } }} {...props} > diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 1c3fd46f..52c8cde7 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -261,7 +261,7 @@ export const PlayButton: React.FC = ({ if (isOpeningCurrentlyPlayingMedia) { return; } - CastContext.showExpandedControls(); + router.push("/casting-player"); }); } catch (e) { console.log(e); diff --git a/components/casting/CastingMiniPlayer.tsx b/components/casting/CastingMiniPlayer.tsx index 221eae0d..87f8558f 100644 --- a/components/casting/CastingMiniPlayer.tsx +++ b/components/casting/CastingMiniPlayer.tsx @@ -47,7 +47,7 @@ export const CastingMiniPlayer: React.FC = () => { ); const progressPercent = duration > 0 ? (progress / duration) * 100 : 0; - const protocolColor = protocol === "chromecast" ? "#F9AB00" : "#666"; // Google yellow + const protocolColor = "#a855f7"; // Streamyfin purple const handlePress = () => { router.push("/casting-player"); diff --git a/components/chromecast/ChromecastDeviceSheet.tsx b/components/chromecast/ChromecastDeviceSheet.tsx index a7e23369..0747cffd 100644 --- a/components/chromecast/ChromecastDeviceSheet.tsx +++ b/components/chromecast/ChromecastDeviceSheet.tsx @@ -4,7 +4,7 @@ */ import { Ionicons } from "@expo/vector-icons"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Modal, Pressable, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import type { Device } from "react-native-google-cast"; @@ -19,6 +19,7 @@ interface ChromecastDeviceSheetProps { onDisconnect: () => Promise; volume?: number; onVolumeChange?: (volume: number) => Promise; + showTechnicalInfo?: boolean; } export const ChromecastDeviceSheet: React.FC = ({ @@ -28,11 +29,17 @@ export const ChromecastDeviceSheet: React.FC = ({ onDisconnect, volume = 0.5, onVolumeChange, + showTechnicalInfo = false, }) => { const insets = useSafeAreaInsets(); const [isDisconnecting, setIsDisconnecting] = useState(false); const volumeValue = useSharedValue(volume * 100); + // Sync volume slider with prop changes + useEffect(() => { + volumeValue.value = volume * 100; + }, [volume, volumeValue]); + const handleDisconnect = async () => { setIsDisconnecting(true); try { @@ -55,9 +62,8 @@ export const ChromecastDeviceSheet: React.FC = ({ = ({ - + Chromecast @@ -111,7 +117,7 @@ export const ChromecastDeviceSheet: React.FC = ({ - {device?.deviceId && ( + {device?.deviceId && showTechnicalInfo && ( Device ID @@ -153,8 +159,8 @@ export const ChromecastDeviceSheet: React.FC = ({ theme={{ disableMinTrackTintColor: "#333", maximumTrackTintColor: "#333", - minimumTrackTintColor: "#e50914", - bubbleBackgroundColor: "#e50914", + minimumTrackTintColor: "#a855f7", + bubbleBackgroundColor: "#a855f7", }} onValueChange={(value) => { volumeValue.value = value; @@ -171,7 +177,7 @@ export const ChromecastDeviceSheet: React.FC = ({ onPress={handleDisconnect} disabled={isDisconnecting} style={{ - backgroundColor: "#e50914", + backgroundColor: "#a855f7", padding: 16, borderRadius: 8, flexDirection: "row", diff --git a/components/chromecast/ChromecastEpisodeList.tsx b/components/chromecast/ChromecastEpisodeList.tsx index 85d988b9..1a14453d 100644 --- a/components/chromecast/ChromecastEpisodeList.tsx +++ b/components/chromecast/ChromecastEpisodeList.tsx @@ -41,7 +41,7 @@ export const ChromecastEpisodeList: React.FC = ({ style={{ flexDirection: "row", padding: 12, - backgroundColor: isCurrentEpisode ? "#e50914" : "transparent", + backgroundColor: isCurrentEpisode ? "#a855f7" : "transparent", borderRadius: 8, marginBottom: 8, }} @@ -131,7 +131,7 @@ export const ChromecastEpisodeList: React.FC = ({ = ({ = ({ )} {selectedMediaSource?.id === source.id && ( - + )} ))} )} - {/* Audio Tracks */} - {renderSectionHeader("Audio", "musical-notes", "audio")} - {expandedSection === "audio" && ( + {/* Audio Tracks - only show if more than one track */} + {audioTracks.length > 1 && + renderSectionHeader("Audio", "musical-notes", "audio")} + {audioTracks.length > 1 && expandedSection === "audio" && ( {audioTracks.map((track) => ( = ({ )} {selectedAudioTrack?.index === track.index && ( - + )} ))} )} - {/* Subtitle Tracks */} - {renderSectionHeader("Subtitles", "text", "subtitles")} - {expandedSection === "subtitles" && ( + {/* Subtitle Tracks - only show if subtitles available */} + {subtitleTracks.length > 0 && + renderSectionHeader("Subtitles", "text", "subtitles")} + {subtitleTracks.length > 0 && expandedSection === "subtitles" && ( { @@ -243,7 +244,7 @@ export const ChromecastSettingsMenu: React.FC = ({ > None {selectedSubtitleTrack === null && ( - + )} {subtitleTracks.map((track) => ( @@ -278,7 +279,7 @@ export const ChromecastSettingsMenu: React.FC = ({ )} {selectedSubtitleTrack?.index === track.index && ( - + )} ))} @@ -309,7 +310,7 @@ export const ChromecastSettingsMenu: React.FC = ({ {speed === 1 ? "Normal" : `${speed}x`} {playbackSpeed === speed && ( - + )} ))} @@ -343,7 +344,7 @@ export const ChromecastSettingsMenu: React.FC = ({ width: 50, height: 30, borderRadius: 15, - backgroundColor: showTechnicalInfo ? "#e50914" : "#333", + backgroundColor: showTechnicalInfo ? "#a855f7" : "#333", justifyContent: "center", alignItems: showTechnicalInfo ? "flex-end" : "flex-start", padding: 2, diff --git a/components/item/ItemPeopleSections.tsx b/components/item/ItemPeopleSections.tsx index 0b16271f..24dc6f1a 100644 --- a/components/item/ItemPeopleSections.tsx +++ b/components/item/ItemPeopleSections.tsx @@ -47,7 +47,7 @@ export const ItemPeopleSections: React.FC = ({ item, ...props }) => { return (