From f6a47b98676ba1606103cb4d7d6dba71915d610d Mon Sep 17 00:00:00 2001 From: Uruk Date: Wed, 4 Feb 2026 21:03:49 +0100 Subject: [PATCH] fix: Refactors Chromecast casting player Refactors the Chromecast casting player for better compatibility, UI improvements, and stability. - Adds auto-selection of stereo audio tracks for improved Chromecast compatibility - Refactors episode list to filter out virtual episodes and allow season selection - Improves UI layout and styling - Removes connection quality indicator - Fixes progress reporting to Jellyfin - Updates volume control to use CastSession for device volume --- app/(auth)/casting-player.tsx | 305 +++++++++--------- components/casting/CastingMiniPlayer.tsx | 129 +++++--- .../chromecast/ChromecastDeviceSheet.tsx | 114 +++---- .../chromecast/ChromecastEpisodeList.tsx | 115 ++++++- .../chromecast/ChromecastSettingsMenu.tsx | 48 --- hooks/useCasting.ts | 189 +++++++++-- translations/en.json | 19 +- utils/profiles/chromecast.ts | 8 + utils/profiles/chromecasth265.ts | 9 +- 9 files changed, 569 insertions(+), 367 deletions(-) diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 563a5482..71c2ffd3 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -13,7 +13,7 @@ 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 { +import GoogleCast, { CastState, MediaPlayerState, useCastDevice, @@ -39,7 +39,6 @@ import { useSettings } from "@/utils/atoms/settings"; import { calculateEndingTime, formatTime, - getConnectionQuality, getPosterUrl, shouldShowNextEpisodeCountdown, truncateTitle, @@ -188,7 +187,6 @@ export default function CastingPlayerScreen() { 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); @@ -316,6 +314,32 @@ export default function CastingPlayerScreen() { return variants; }, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]); + // Auto-select stereo audio track for better Chromecast compatibility + useEffect(() => { + if (!remoteMediaClient || !mediaStatus?.mediaInfo) return; + + const currentTrack = availableAudioTracks.find( + (t) => t.index === selectedAudioTrackIndex, + ); + + // If current track is 5.1+ audio, try to switch to stereo + if (currentTrack && (currentTrack.channels || 0) > 2) { + const stereoTrack = availableAudioTracks.find((t) => t.channels === 2); + if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) { + console.log( + "[Audio] Switching from 5.1 to stereo for better compatibility:", + currentTrack.displayTitle, + "->", + stereoTrack.displayTitle, + ); + setSelectedAudioTrackIndex(stereoTrack.index); + remoteMediaClient + .setActiveTrackIds([stereoTrack.index]) + .catch(console.error); + } + } + }, [mediaStatus?.mediaInfo, availableAudioTracks, remoteMediaClient]); + // Fetch episodes for TV shows useEffect(() => { if (currentItem?.Type !== "Episode" || !currentItem.SeriesId || !api) @@ -324,9 +348,10 @@ export default function CastingPlayerScreen() { const fetchEpisodes = async () => { try { const tvShowsApi = getTvShowsApi(api); + // Fetch ALL episodes from ALL seasons by removing seasonId filter const response = await tvShowsApi.getEpisodes({ seriesId: currentItem.SeriesId!, - seasonId: currentItem.SeasonId || undefined, + // Don't filter by seasonId - get all seasons userId: api.accessToken ? undefined : "", }); @@ -475,8 +500,8 @@ export default function CastingPlayerScreen() { { 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`; + ? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}` + : `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`; } // Fallback to item poster for non-episodes or if season data not loaded @@ -510,27 +535,6 @@ export default function CastingPlayerScreen() { const protocolColor = "#a855f7"; // Streamyfin purple - const connectionQuality = useMemo(() => { - const bitrate = availableMediaSources[0]?.bitrate; - return getConnectionQuality(bitrate); - }, [availableMediaSources]); - - // 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; const remaining = duration - progress; @@ -659,14 +663,12 @@ export default function CastingPlayerScreen() { {currentDevice || "Unknown Device"} - {/* Connection quality indicator with color */} - @@ -727,12 +729,12 @@ export default function CastingPlayerScreen() { - {/* Poster with buffering overlay - reduced size */} + {/* Poster with buffering overlay */} + - {/* Spacer to push buttons down */} - - - {/* 4-button control row for episodes */} - {currentItem.Type === "Episode" && ( - + {/* Episodes button */} + setShowEpisodeList(true)} style={{ + flex: 1, + backgroundColor: "#1a1a1a", + padding: 12, + borderRadius: 12, flexDirection: "row", justifyContent: "center", alignItems: "center", - gap: 16, - marginBottom: 40, - paddingHorizontal: 20, }} > - {/* Episodes button */} - setShowEpisodeList(true)} - style={{ - flex: 1, - backgroundColor: "#1a1a1a", - padding: 12, - borderRadius: 12, - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - }} - > - - + + - {/* 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 + {/* 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); } - style={{ - flex: 1, - backgroundColor: "#1a1a1a", - padding: 12, - borderRadius: 12, - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - opacity: - episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0 - ? 0.4 - : 1, - }} - > - - + }} + disabled={ + episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0 + } + style={{ + flex: 1, + backgroundColor: "#1a1a1a", + padding: 12, + borderRadius: 12, + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + opacity: + episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0 + ? 0.4 + : 1, + }} + > + + - {/* 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", - opacity: nextEpisode ? 1 : 0.4, - }} - > - - + {/* 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", + opacity: nextEpisode ? 1 : 0.4, + }} + > + + - {/* Stop casting button */} - { - 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)/"); - } + {/* Stop casting button */} + { + try { + // End the casting session and stop the receiver + const sessionManager = GoogleCast.getSessionManager(); + await sessionManager.endCurrentSession(true); + + // Navigate back + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(auth)/(tabs)/(home)/"); } - }} - style={{ - flex: 1, - backgroundColor: "#1a1a1a", - padding: 12, - borderRadius: 12, - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - }} - > - - - - )} - + } catch (error) { + console.error( + "[Casting Player] Error disconnecting:", + error, + ); + // Try to navigate anyway + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(auth)/(tabs)/(home)/"); + } + } + }} + style={{ + flex: 1, + backgroundColor: "#1a1a1a", + padding: 12, + borderRadius: 12, + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + }} + > + + + + )} {/* Fixed bottom controls area */} { try { await stop(); @@ -1172,7 +1180,6 @@ export default function CastingPlayerScreen() { onVolumeChange={async (vol) => { setVolume(vol); }} - showTechnicalInfo={showTechnicalInfo} /> { - setShowTechnicalInfo(!showTechnicalInfo); - }} /> diff --git a/components/casting/CastingMiniPlayer.tsx b/components/casting/CastingMiniPlayer.tsx index f1b7b33f..0d245257 100644 --- a/components/casting/CastingMiniPlayer.tsx +++ b/components/casting/CastingMiniPlayer.tsx @@ -4,58 +4,111 @@ */ import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; import { router } from "expo-router"; import { useAtomValue } from "jotai"; -import React from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Pressable, View } from "react-native"; +import { + MediaPlayerState, + useCastDevice, + useMediaStatus, + useRemoteMediaClient, +} from "react-native-google-cast"; import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; -import { useCasting } from "@/hooks/useCasting"; import { apiAtom } from "@/providers/JellyfinProvider"; -import { - formatTime, - getPosterUrl, - getProtocolIcon, - getProtocolName, -} from "@/utils/casting/helpers"; +import { formatTime, getPosterUrl } from "@/utils/casting/helpers"; import { CASTING_CONSTANTS } from "@/utils/casting/types"; export const CastingMiniPlayer: React.FC = () => { const api = useAtomValue(apiAtom); const insets = useSafeAreaInsets(); - const { - isConnected, - protocol, - currentItem, - currentDevice, - progress, - duration, - isPlaying, - togglePlayPause, - } = useCasting(null); + const castDevice = useCastDevice(); + const mediaStatus = useMediaStatus(); + const remoteMediaClient = useRemoteMediaClient(); - if (!isConnected || !currentItem || !protocol) { + const currentItem = useMemo(() => { + return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined; + }, [mediaStatus?.mediaInfo?.customData]); + + // Live progress state that updates every second when playing + const [liveProgress, setLiveProgress] = useState( + mediaStatus?.streamPosition || 0, + ); + + // Sync live progress with mediaStatus and poll every second when playing + useEffect(() => { + 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]); + + const progress = liveProgress * 1000; // Convert to ms + const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000; + const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING; + + // For episodes, use season poster; for other content, use item poster + const posterUrl = useMemo(() => { + if (!api?.basePath || !currentItem) return null; + + if ( + currentItem.Type === "Episode" && + currentItem.SeriesId && + currentItem.ParentIndexNumber + ) { + // Build season poster URL using SeriesId and season number + return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96&tag=${currentItem.SeasonId}`; + } + + // For non-episodes, use item's own poster + return getPosterUrl( + api.basePath, + currentItem.Id, + currentItem.ImageTags?.Primary, + 80, + 120, + ); + }, [api?.basePath, currentItem]); + + if (!castDevice || !currentItem || !mediaStatus) { return null; } - const posterUrl = getPosterUrl( - api?.basePath, - currentItem.Id, - currentItem.ImageTags?.Primary, - 80, - 120, - ); - const progressPercent = duration > 0 ? (progress / duration) * 100 : 0; const protocolColor = "#a855f7"; // Streamyfin purple - const TAB_BAR_HEIGHT = 49; // Standard tab bar height + const TAB_BAR_HEIGHT = 80; // Standard tab bar height const handlePress = () => { router.push("/casting-player"); }; + const handleTogglePlayPause = (e: any) => { + e.stopPropagation(); + if (isPlaying) { + remoteMediaClient?.pause(); + } else { + remoteMediaClient?.play(); + } + }; + return ( { marginTop: 2, }} > - + { }} numberOfLines={1} > - {currentDevice?.name || getProtocolName(protocol)} + {castDevice.friendlyName || "Chromecast"} { {/* Play/Pause button */} - { - e.stopPropagation(); - if (isConnected && protocol) { - togglePlayPause(); - } - }} - style={{ - padding: 8, - }} - > + Promise; volume?: number; onVolumeChange?: (volume: number) => Promise; - showTechnicalInfo?: boolean; - connectionQuality?: "excellent" | "good" | "fair" | "poor"; - bitrate?: number; } export const ChromecastDeviceSheet: React.FC = ({ @@ -31,19 +29,34 @@ export const ChromecastDeviceSheet: React.FC = ({ onDisconnect, volume = 0.5, onVolumeChange, - showTechnicalInfo = false, - connectionQuality = "good", - bitrate, }) => { const insets = useSafeAreaInsets(); const [isDisconnecting, setIsDisconnecting] = useState(false); const volumeValue = useSharedValue(volume * 100); + const minimumValue = useSharedValue(0); + const maximumValue = useSharedValue(100); + const castSession = useCastSession(); + const remoteMediaClient = useRemoteMediaClient(); - // Sync volume slider with prop changes + // Sync volume slider with prop changes (updates from physical buttons) useEffect(() => { volumeValue.value = volume * 100; }, [volume, volumeValue]); + // Poll for volume updates when sheet is visible to catch physical button changes + useEffect(() => { + if (!visible || !remoteMediaClient) return; + + // Request status update to get latest volume from device + const interval = setInterval(() => { + remoteMediaClient.requestStatus().catch(() => { + // Ignore errors - device might be disconnected + }); + }, 1000); + + return () => clearInterval(interval); + }, [visible, remoteMediaClient]); + const handleDisconnect = async () => { setIsDisconnecting(true); try { @@ -57,8 +70,19 @@ export const ChromecastDeviceSheet: React.FC = ({ }; const handleVolumeComplete = async (value: number) => { - if (onVolumeChange) { - await onVolumeChange(value / 100); + const newVolume = value / 100; + try { + // Use CastSession.setVolume for DEVICE volume control + // This works even when no media is playing, unlike setStreamVolume + if (castSession) { + castSession.setVolume(newVolume); + console.log("[Volume] Set device volume via CastSession:", newVolume); + } else if (onVolumeChange) { + // Fallback to prop method if session not available + await onVolumeChange(newVolume); + } + } catch (error) { + console.error("[Volume] Error setting volume:", error); } }; @@ -120,61 +144,7 @@ export const ChromecastDeviceSheet: React.FC = ({ {device?.friendlyName || device?.deviceId || "Unknown Device"} - - {/* Connection Quality */} - - - Connection Quality - - - - - {connectionQuality} - - - {bitrate && ( - - Bitrate: {(bitrate / 1000000).toFixed(1)} Mbps - {connectionQuality === "poor" && - " (Low bitrate may cause buffering)"} - {connectionQuality === "fair" && " (Moderate quality)"} - {connectionQuality === "good" && " (Good quality)"} - {connectionQuality === "excellent" && " (Maximum quality)"} - - )} - - - {device?.deviceId && showTechnicalInfo && ( + {device?.deviceId && ( Device ID @@ -183,11 +153,10 @@ export const ChromecastDeviceSheet: React.FC = ({ style={{ color: "white", fontSize: 14 }} numberOfLines={1} > - {device.deviceId} + {device?.deviceId} )} - {/* Volume control */} = ({ = ({ }} onSlidingComplete={handleVolumeComplete} panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }} - disable={false} /> - {/* Disconnect button */} = ({ opacity: isDisconnecting ? 0.5 : 1, }} > - + {isDisconnecting ? "Disconnecting..." : "Stop Casting"} diff --git a/components/chromecast/ChromecastEpisodeList.tsx b/components/chromecast/ChromecastEpisodeList.tsx index eb2e056a..5235add9 100644 --- a/components/chromecast/ChromecastEpisodeList.tsx +++ b/components/chromecast/ChromecastEpisodeList.tsx @@ -7,8 +7,8 @@ import { Ionicons } from "@expo/vector-icons"; import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; -import React, { useEffect, useRef } from "react"; -import { FlatList, Modal, Pressable, View } from "react-native"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { FlatList, Modal, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { truncateTitle } from "@/utils/casting/helpers"; @@ -33,10 +33,47 @@ export const ChromecastEpisodeList: React.FC = ({ }) => { const insets = useSafeAreaInsets(); const flatListRef = useRef(null); + const [selectedSeason, setSelectedSeason] = useState(null); + + // Get unique seasons from episodes + const seasons = useMemo(() => { + const seasonSet = new Set(); + for (const ep of episodes) { + if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) { + seasonSet.add(ep.ParentIndexNumber); + } + } + return Array.from(seasonSet).sort((a, b) => a - b); + }, [episodes]); + + // Filter episodes by selected season and exclude virtual episodes + const filteredEpisodes = useMemo(() => { + let eps = episodes; + + // Filter by season if selected + if (selectedSeason !== null) { + eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason); + } + + // Filter out virtual episodes (episodes without actual video files) + // LocationType === "Virtual" means the episode doesn't have a media file + eps = eps.filter((ep) => ep.LocationType !== "Virtual"); + + return eps; + }, [episodes, selectedSeason]); + + // Set initial season to current episode's season + useEffect(() => { + if (currentItem?.ParentIndexNumber !== undefined) { + setSelectedSeason(currentItem.ParentIndexNumber); + } + }, [currentItem]); useEffect(() => { - if (visible && currentItem && episodes.length > 0) { - const currentIndex = episodes.findIndex((ep) => ep.Id === currentItem.Id); + if (visible && currentItem && filteredEpisodes.length > 0) { + const currentIndex = filteredEpisodes.findIndex( + (ep) => ep.Id === currentItem.Id, + ); if (currentIndex !== -1 && flatListRef.current) { // Delay to ensure FlatList is rendered setTimeout(() => { @@ -48,7 +85,7 @@ export const ChromecastEpisodeList: React.FC = ({ }, 300); } } - }, [visible, currentItem, episodes]); + }, [visible, currentItem, filteredEpisodes]); const renderEpisode = ({ item }: { item: BaseItemDto }) => { const isCurrentEpisode = item.Id === currentItem?.Id; @@ -125,6 +162,15 @@ export const ChromecastEpisodeList: React.FC = ({ )} + {item.ParentIndexNumber !== undefined && + item.IndexNumber !== undefined && ( + + S{String(item.ParentIndexNumber).padStart(2, "0")}:E + {String(item.IndexNumber).padStart(2, "0")} + + )} {item.ProductionYear && ( {item.ProductionYear} @@ -176,27 +222,66 @@ export const ChromecastEpisodeList: React.FC = ({ {/* Header */} - - Episodes - - - - + 1 ? 12 : 0, + }} + > + + Episodes + + + + + + + {/* Season selector */} + {seasons.length > 1 && ( + + {seasons.map((season) => ( + setSelectedSeason(season)} + style={{ + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: + selectedSeason === season ? "#a855f7" : "#1a1a1a", + }} + > + + Season {season} + + + ))} + + )} {/* Episode list */} item.Id || ""} contentContainerStyle={{ diff --git a/components/chromecast/ChromecastSettingsMenu.tsx b/components/chromecast/ChromecastSettingsMenu.tsx index ccd70300..14f18325 100644 --- a/components/chromecast/ChromecastSettingsMenu.tsx +++ b/components/chromecast/ChromecastSettingsMenu.tsx @@ -30,8 +30,6 @@ interface ChromecastSettingsMenuProps { onSubtitleTrackChange: (track: SubtitleTrack | null) => void; playbackSpeed: number; onPlaybackSpeedChange: (speed: number) => void; - showTechnicalInfo: boolean; - onToggleTechnicalInfo: () => void; } const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; @@ -51,8 +49,6 @@ export const ChromecastSettingsMenu: React.FC = ({ onSubtitleTrackChange, playbackSpeed, onPlaybackSpeedChange, - showTechnicalInfo, - onToggleTechnicalInfo, }) => { const insets = useSafeAreaInsets(); const [expandedSection, setExpandedSection] = useState(null); @@ -316,50 +312,6 @@ export const ChromecastSettingsMenu: React.FC = ({ ))} )} - - {/* Technical Info Toggle */} - - - - - Show Technical Info - - - - - - diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts index 5f2552eb..31708129 100644 --- a/hooks/useCasting.ts +++ b/hooks/useCasting.ts @@ -10,6 +10,7 @@ import { useAtomValue } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import { useCastDevice, + useCastSession, useMediaStatus, useRemoteMediaClient, } from "react-native-google-cast"; @@ -30,6 +31,7 @@ export const useCasting = (item: BaseItemDto | null) => { const client = useRemoteMediaClient(); const castDevice = useCastDevice(); const mediaStatus = useMediaStatus(); + const castSession = useCastSession(); // Local state const [state, setState] = useState(DEFAULT_CAST_STATE); @@ -37,6 +39,7 @@ export const useCasting = (item: BaseItemDto | null) => { const controlsTimeoutRef = useRef(null); const lastReportedProgressRef = useRef(0); const volumeDebounceRef = useRef(null); + const hasReportedStartRef = useRef(null); // Track which item we reported start for // Detect which protocol is active const chromecastConnected = castDevice !== null; @@ -85,44 +88,118 @@ export const useCasting = (item: BaseItemDto | null) => { } }, [mediaStatus, activeProtocol]); - // Chromecast: Sync volume from device + // Chromecast: Sync volume from device (both mediaStatus and CastSession) useEffect(() => { - if (activeProtocol === "chromecast" && mediaStatus?.volume !== undefined) { + if (activeProtocol !== "chromecast") return; + + // Sync from mediaStatus when available + if (mediaStatus?.volume !== undefined) { setState((prev) => ({ ...prev, volume: mediaStatus.volume, })); } - }, [mediaStatus?.volume, activeProtocol]); - // Progress reporting to Jellyfin (optimized to skip redundant reports) + // Also poll CastSession for device volume to catch physical button changes + if (castSession) { + const volumeInterval = setInterval(() => { + castSession + .getVolume() + .then((deviceVolume) => { + if (deviceVolume !== undefined) { + setState((prev) => { + // Only update if significantly different to avoid jitter + if (Math.abs(prev.volume - deviceVolume) > 0.01) { + return { ...prev, volume: deviceVolume }; + } + return prev; + }); + } + }) + .catch(() => { + // Ignore errors - device might be disconnected + }); + }, 500); // Check every 500ms + + return () => clearInterval(volumeInterval); + } + }, [mediaStatus?.volume, castSession, activeProtocol]); + + // Progress reporting to Jellyfin (matches native player behavior) useEffect(() => { - if (!isConnected || !item?.Id || !user?.Id || state.progress <= 0) return; + if (!isConnected || !item?.Id || !user?.Id || !api) return; + + const playStateApi = getPlaystateApi(api); + + // Report playback start when media begins (only once per item) + if (hasReportedStartRef.current !== item.Id && state.progress > 0) { + playStateApi + .reportPlaybackStart({ + playbackStartInfo: { + ItemId: item.Id, + PositionTicks: Math.floor(state.progress * 10000), + PlayMethod: + activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay", + VolumeLevel: Math.floor(state.volume * 100), + IsMuted: state.volume === 0, + PlaySessionId: mediaStatus?.mediaInfo?.contentId, + }, + }) + .then(() => { + hasReportedStartRef.current = item.Id || null; + }) + .catch((error) => { + console.error("[useCasting] Failed to report playback start:", error); + }); + } const reportProgress = () => { - const progressSeconds = Math.floor(state.progress / 1000); + // Don't report if no meaningful progress or if buffering + if (state.progress <= 0 || state.isBuffering) return; - // Skip if progress hasn't changed significantly (less than 5 seconds) - if (Math.abs(progressSeconds - lastReportedProgressRef.current) < 5) { + const progressMs = Math.floor(state.progress); + const progressTicks = progressMs * 10000; // Convert ms to ticks + const progressSeconds = Math.floor(progressMs / 1000); + + // When paused, always report to keep server in sync + // When playing, skip if progress hasn't changed significantly (less than 3 seconds) + if ( + state.isPlaying && + Math.abs(progressSeconds - lastReportedProgressRef.current) < 3 + ) { return; } lastReportedProgressRef.current = progressSeconds; - const playStateApi = api ? getPlaystateApi(api) : null; + playStateApi - ?.reportPlaybackProgress({ + .reportPlaybackProgress({ playbackProgressInfo: { ItemId: item.Id, - PositionTicks: progressSeconds * 10000000, + PositionTicks: progressTicks, IsPaused: !state.isPlaying, PlayMethod: activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay", + // Add volume level for server tracking + VolumeLevel: Math.floor(state.volume * 100), + IsMuted: state.volume === 0, + // Include play session ID if available + PlaySessionId: mediaStatus?.mediaInfo?.contentId, }, }) - .catch(console.error); + .catch((error) => { + console.error("[useCasting] Failed to report progress:", error); + }); }; - const interval = setInterval(reportProgress, 10000); + // Report immediately on play/pause state change + reportProgress(); + + // Report every 5 seconds when paused, every 10 seconds when playing + const interval = setInterval( + reportProgress, + state.isPlaying ? 10000 : 5000, + ); return () => clearInterval(interval); }, [ api, @@ -130,17 +207,32 @@ export const useCasting = (item: BaseItemDto | null) => { user?.Id, state.progress, state.isPlaying, + state.isBuffering, // Add buffering state to dependencies + state.volume, isConnected, activeProtocol, + mediaStatus?.mediaInfo?.contentId, ]); // Play/Pause controls const play = useCallback(async () => { if (activeProtocol === "chromecast") { - await client?.play(); + // Check if there's an active media session + if (!client || !mediaStatus?.mediaInfo) { + console.warn( + "[useCasting] Cannot play - no active media session. Media needs to be loaded first.", + ); + return; + } + try { + await client.play(); + } catch (error) { + console.error("[useCasting] Error playing:", error); + throw error; + } } // Future: Add play control for other protocols - }, [client, activeProtocol]); + }, [client, mediaStatus, activeProtocol]); const pause = useCallback(async () => { if (activeProtocol === "chromecast") { @@ -160,12 +252,31 @@ export const useCasting = (item: BaseItemDto | null) => { // Seek controls const seek = useCallback( async (positionMs: number) => { + // Validate position + if (positionMs < 0 || !Number.isFinite(positionMs)) { + console.error("[useCasting] Invalid seek position (ms):", positionMs); + return; + } + + const positionSeconds = positionMs / 1000; + + // Additional validation for Chromecast if (activeProtocol === "chromecast") { - await client?.seek({ position: positionMs / 1000 }); + if (positionSeconds > state.duration) { + console.warn( + "[useCasting] Seek position exceeds duration, clamping:", + positionSeconds, + "->", + state.duration, + ); + await client?.seek({ position: state.duration }); + return; + } + await client?.seek({ position: positionSeconds }); } // Future: Add seek control for other protocols }, - [client, activeProtocol], + [client, activeProtocol, state.duration], ); const skipForward = useCallback( @@ -185,25 +296,33 @@ export const useCasting = (item: BaseItemDto | null) => { ); // Stop and disconnect - const stop = useCallback(async () => { - if (activeProtocol === "chromecast") { - await client?.stop(); - } - // Future: Add stop control for other protocols + const stop = useCallback( + async (onStopComplete?: () => void) => { + 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: state.progress * 10000, + }, + }); + } - setState(DEFAULT_CAST_STATE); - }, [client, api, item?.Id, user?.Id, state.progress, activeProtocol]); + setState(DEFAULT_CAST_STATE); + + // Call callback after stop completes (e.g., to navigate away) + if (onStopComplete) { + onStopComplete(); + } + }, + [client, api, item?.Id, user?.Id, state.progress, activeProtocol], + ); // Volume control (debounced to reduce API calls) const setVolume = useCallback( @@ -220,6 +339,8 @@ export const useCasting = (item: BaseItemDto | null) => { volumeDebounceRef.current = setTimeout(async () => { if (activeProtocol === "chromecast" && client && isConnected) { + // Use setStreamVolume for media stream volume (0.0 - 1.0) + // Physical volume buttons are handled automatically by the framework await client.setStreamVolume(clampedVolume).catch((error) => { console.log( "[useCasting] Volume set failed (no session):", diff --git a/translations/en.json b/translations/en.json index 4f57aa99..4c56faf8 100644 --- a/translations/en.json +++ b/translations/en.json @@ -51,13 +51,28 @@ }, "casting_player": { "buffering": "Buffering...", + "changing_audio": "Changing audio...", + "changing_subtitles": "Changing subtitles...", "season_episode_format": "Season {{season}} • Episode {{episode}}", "connection_quality": { "excellent": "Excellent", "good": "Good", "fair": "Fair", - "poor": "Poor" - } + "poor": "Poor", + "disconnected": "Disconnected" + }, + "error_title": "Chromecast Error", + "error_description": "Something went wrong with the cast session", + "retry": "Try Again", + "critical_error_title": "Multiple Errors Detected", + "critical_error_description": "Chromecast encountered multiple errors. Please disconnect and try casting again.", + "track_changed": "Track changed successfully", + "audio_track_changed": "Audio track changed", + "subtitle_track_changed": "Subtitle track changed", + "seeking": "Seeking...", + "seeking_error": "Failed to seek", + "load_failed": "Failed to load media", + "load_retry": "Retrying media load..." }, "server": { "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", diff --git a/utils/profiles/chromecast.ts b/utils/profiles/chromecast.ts index 3844e699..2863b4b0 100644 --- a/utils/profiles/chromecast.ts +++ b/utils/profiles/chromecast.ts @@ -13,6 +13,14 @@ export const chromecast: DeviceProfile = { { Type: "Audio", Codec: "aac,mp3,flac,opus,vorbis", + // Force transcode if audio has more than 2 channels (5.1, 7.1, etc) + Conditions: [ + { + Condition: "LessThanEqual", + Property: "AudioChannels", + Value: "2", + }, + ], }, ], ContainerProfiles: [], diff --git a/utils/profiles/chromecasth265.ts b/utils/profiles/chromecasth265.ts index 42bb1712..315bcdff 100644 --- a/utils/profiles/chromecasth265.ts +++ b/utils/profiles/chromecasth265.ts @@ -12,7 +12,14 @@ export const chromecasth265: DeviceProfile = { }, { Type: "Audio", - Codec: "aac,mp3,flac,opus,vorbis", + Codec: "aac,mp3,flac,opus,vorbis", // Force transcode if audio has more than 2 channels (5.1, 7.1, etc) + Conditions: [ + { + Condition: "LessThanEqual", + Property: "AudioChannels", + Value: "2", + }, + ], }, ], ContainerProfiles: [],