diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 71636b174..d0d9fae1d 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -35,15 +35,18 @@ import Animated, { withSpring, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { BITRATES } from "@/components/BitrateSelector"; import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet"; import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList"; import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu"; import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments"; import { Text } from "@/components/common/Text"; import { useCasting } from "@/hooks/useCasting"; +import { useCastSelection } from "@/hooks/useCastSelection"; import { useTrickplay } from "@/hooks/useTrickplay"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { detectCapabilities } from "@/utils/casting/capabilities"; import { loadCastMedia } from "@/utils/casting/castLoad"; import { calculateEndingTime, @@ -52,6 +55,8 @@ import { getPosterUrl, truncateTitle, } from "@/utils/casting/helpers"; +import { resolveSelection } from "@/utils/casting/selection"; +import type { CastSelection } from "@/utils/casting/types"; import { msToTicks, ticksToSeconds } from "@/utils/time"; export default function CastingPlayerScreen() { @@ -238,94 +243,40 @@ export default function CastingPlayerScreen() { const [nextEpisode, setNextEpisode] = useState(null); const [seasonData, setSeasonData] = useState(null); - // Track selection states - // null = not yet initialized (use server default), -1 = subtitles off, >= 0 = specific track - const [selectedAudioTrackIndex, setSelectedAudioTrackIndex] = useState< - number | null - >(null); - const [selectedSubtitleTrackIndex, setSelectedSubtitleTrackIndex] = useState< - number | null - >(null); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1); - // Initialize track selection from server defaults when item data arrives - useEffect(() => { - if (!fetchedItem) return; - const source = fetchedItem.MediaSources?.[0]; - if (source) { - if (source.DefaultAudioStreamIndex != null) { - setSelectedAudioTrackIndex(source.DefaultAudioStreamIndex); - } - // Jellyfin uses -1 for "no subtitles", >= 0 for a specific track - const defaultSub = source.DefaultSubtitleStreamIndex; - setSelectedSubtitleTrackIndex(defaultSub ?? -1); - return; - } - // Fallback: scan MediaStreams for IsDefault flags - if (fetchedItem.MediaStreams) { - const defaultAudio = fetchedItem.MediaStreams.find( - (s) => s.Type === "Audio" && s.IsDefault, - ); - if (defaultAudio?.Index != null) { - setSelectedAudioTrackIndex(defaultAudio.Index); - } - const defaultSub = fetchedItem.MediaStreams.find( - (s) => s.Type === "Subtitle" && s.IsDefault, - ); - setSelectedSubtitleTrackIndex(defaultSub?.Index ?? -1); - } - }, [fetchedItem?.Id]); - - // Function to reload media with new audio/subtitle/quality settings - const reloadWithSettings = useCallback( - async (options: { - audioIndex?: number; - subtitleIndex?: number | null; - bitrateValue?: number; - }) => { + // Reload the cast stream with a full selection; resolves true on success. + const reloadWithSelection = useCallback( + async (selection: CastSelection): Promise => { if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) { console.warn("[Casting Player] Cannot reload - missing required data"); - return; + return false; } - - try { - const currentPosition = mediaStatus?.streamPosition ?? 0; - - let resolvedSubtitleIndex: number | undefined; - if (options.subtitleIndex === undefined) { - resolvedSubtitleIndex = selectedSubtitleTrackIndex ?? undefined; - } else if (options.subtitleIndex === null) { - resolvedSubtitleIndex = -1; - } else { - resolvedSubtitleIndex = options.subtitleIndex; - } - - const result = await loadCastMedia({ - client: remoteMediaClient, - device: castDevice, - api, - item: currentItem, - userId: user.Id, - profileMode: settings.chromecastProfile, - maxBitrateSetting: settings.chromecastMaxBitrate, - options: { - audioStreamIndex: - options.audioIndex ?? selectedAudioTrackIndex ?? undefined, - subtitleStreamIndex: resolvedSubtitleIndex, - maxBitrate: options.bitrateValue, - startPositionMs: currentPosition * 1000, - }, - }); - - if (!result.ok) { - console.error( - "[Casting Player] Failed to reload stream:", - result.error, - ); - } - } catch (error) { - console.error("[Casting Player] Failed to reload stream:", error); + const currentPosition = mediaStatus?.streamPosition ?? 0; + const result = await loadCastMedia({ + client: remoteMediaClient, + device: castDevice, + api, + item: currentItem, + userId: user.Id, + profileMode: settings.chromecastProfile, + maxBitrateSetting: settings.chromecastMaxBitrate, + options: { + mediaSourceId: selection.mediaSourceId, + audioStreamIndex: selection.audioStreamIndex, + subtitleStreamIndex: selection.subtitleStreamIndex, + maxBitrate: selection.maxBitrate, + startPositionMs: currentPosition * 1000, + }, + }); + if (!result.ok) { + console.error( + "[Casting Player] Failed to reload stream:", + result.error, + ); + return false; } + return true; }, [ api, @@ -336,11 +287,15 @@ export default function CastingPlayerScreen() { mediaStatus?.streamPosition, settings.chromecastProfile, settings.chromecastMaxBitrate, - selectedAudioTrackIndex, - selectedSubtitleTrackIndex, ], ); + const { currentSelection, applySelection } = useCastSelection({ + currentItem, + mediaStatus, + reload: reloadWithSelection, + }); + // Load a different episode on the Chromecast const loadEpisode = useCallback( async (episode: BaseItemDto) => { @@ -368,9 +323,6 @@ export default function CastingPlayerScreen() { ); return; } - - setSelectedAudioTrackIndex(null); - setSelectedSubtitleTrackIndex(null); } catch (error) { console.error("[Casting Player] Failed to load episode:", error); } @@ -412,87 +364,86 @@ export default function CastingPlayerScreen() { fetchSeasonData(); }, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]); - const availableAudioTracks = useMemo(() => { - if (!currentItem?.MediaStreams) return []; + // The MediaSource currently selected, for deriving its tracks. + const selectedSource = useMemo( + () => + currentItem?.MediaSources?.find( + (s) => s.Id === currentSelection?.mediaSourceId, + ) ?? + currentItem?.MediaSources?.[0] ?? + null, + [currentItem?.MediaSources, currentSelection?.mediaSourceId], + ); - 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]); + // Real alternate versions (multi-version items). + const availableVersions = useMemo( + () => + (currentItem?.MediaSources ?? []).map((s, i) => ({ + id: s.Id ?? `source-${i}`, + name: s.Name || `${t("casting_player.version")} ${i + 1}`, + })), + [currentItem?.MediaSources, t], + ); + + // Quality tiers from the shared ladder, capped to BOTH the device's + // capability and the media's own bitrate — a tier above either ceiling + // would behave identically to "Max", so it is not offered. + const availableQualities = useMemo(() => { + const caps = detectCapabilities(castDevice, { + profileMode: settings.chromecastProfile, + maxBitrate: settings.chromecastMaxBitrate, + }); + const mediaBitrate = + selectedSource?.Bitrate ?? + currentItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ?? + Number.POSITIVE_INFINITY; + const ceiling = Math.min(caps.maxVideoBitrate, mediaBitrate); + return BITRATES.filter((b) => b.value === undefined || b.value <= ceiling); + }, [ + castDevice, + settings.chromecastProfile, + settings.chromecastMaxBitrate, + selectedSource, + currentItem?.MediaStreams, + ]); + + const availableAudioTracks = useMemo(() => { + const streams = selectedSource?.MediaStreams ?? currentItem?.MediaStreams; + if (!streams) return []; + return streams + .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, + })); + }, [selectedSource, currentItem?.MediaStreams]); const availableSubtitleTracks = useMemo(() => { - 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}` : "", - ].join(""), - codec: stream.Codec || "Unknown", - isForced: stream.IsForced || false, - isExternal: stream.IsExternal || false, - })); - }, [currentItem?.MediaStreams]); - - const availableMediaSources = useMemo(() => { - // 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]); + const streams = selectedSource?.MediaStreams ?? currentItem?.MediaStreams; + if (!streams) return []; + return streams + .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}` : "", + ].join(""), + codec: stream.Codec || "Unknown", + isForced: stream.IsForced || false, + isExternal: stream.IsExternal || false, + })); + }, [selectedSource, currentItem?.MediaStreams]); // Fetch episodes for TV shows useEffect(() => { @@ -1430,44 +1381,27 @@ export default function CastingPlayerScreen() { setShowSettings(false)} - item={currentItem} - mediaSources={availableMediaSources.filter((source) => { - const currentBitrate = - availableMediaSources[0]?.bitrate || Number.POSITIVE_INFINITY; - return (source.bitrate || 0) <= currentBitrate; - })} - selectedMediaSource={availableMediaSources[0] || null} - onMediaSourceChange={(source) => { - reloadWithSettings({ bitrateValue: source.bitrate }); + versions={availableVersions} + selectedVersionId={currentSelection?.mediaSourceId ?? ""} + onVersionChange={(id) => { + if (!currentItem) return; + applySelection( + resolveSelection(currentItem, { mediaSourceId: id }), + ); }} + qualities={availableQualities} + selectedMaxBitrate={currentSelection?.maxBitrate} + onQualityChange={(value) => applySelection({ maxBitrate: value })} audioTracks={availableAudioTracks} - selectedAudioTrack={ - selectedAudioTrackIndex === null - ? availableAudioTracks[0] || null - : availableAudioTracks.find( - (t) => t.index === selectedAudioTrackIndex, - ) || null + selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1} + onAudioChange={(index) => + applySelection({ audioStreamIndex: index }) } - onAudioTrackChange={(track) => { - setSelectedAudioTrackIndex(track.index); - // Reload stream with new audio track - reloadWithSettings({ audioIndex: track.index }); - }} subtitleTracks={availableSubtitleTracks} - selectedSubtitleTrack={ - selectedSubtitleTrackIndex == null || - selectedSubtitleTrackIndex < 0 - ? null - : availableSubtitleTracks.find( - (t) => t.index === selectedSubtitleTrackIndex, - ) || null + selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1} + onSubtitleChange={(index) => + applySelection({ subtitleStreamIndex: index }) } - onSubtitleTrackChange={(track) => { - // -1 = disabled, >= 0 = specific track - setSelectedSubtitleTrackIndex(track?.index ?? -1); - // Reload stream: null signals disable, number selects track - reloadWithSettings({ subtitleIndex: track?.index ?? null }); - }} playbackSpeed={currentPlaybackSpeed} onPlaybackSpeedChange={(speed) => { setCurrentPlaybackSpeed(speed); diff --git a/components/chromecast/ChromecastSettingsMenu.tsx b/components/chromecast/ChromecastSettingsMenu.tsx index 4e1eb5cdf..400b548cc 100644 --- a/components/chromecast/ChromecastSettingsMenu.tsx +++ b/components/chromecast/ChromecastSettingsMenu.tsx @@ -1,53 +1,65 @@ /** * Chromecast Settings Menu - * Allows users to configure audio, subtitles, quality, and playback speed + * Configure version, quality (bitrate cap), audio, subtitles, and playback speed. + * Every "selected" row is driven by the active CastSelection — no [0] fallbacks. */ import { Ionicons } from "@expo/vector-icons"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Modal, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; -import type { - AudioTrack, - MediaSource, - SubtitleTrack, -} from "@/utils/casting/types"; +import type { AudioTrack, SubtitleTrack } from "@/utils/casting/types"; + +export interface VersionOption { + id: string; + name: string; +} + +export interface QualityOption { + key: string; + value: number | undefined; +} interface ChromecastSettingsMenuProps { visible: boolean; onClose: () => void; - item: BaseItemDto; - mediaSources: MediaSource[]; - selectedMediaSource: MediaSource | null; - onMediaSourceChange: (source: MediaSource) => void; + versions: VersionOption[]; + selectedVersionId: string; + onVersionChange: (id: string) => void; + qualities: QualityOption[]; + selectedMaxBitrate: number | undefined; + onQualityChange: (value: number | undefined) => void; audioTracks: AudioTrack[]; - selectedAudioTrack: AudioTrack | null; - onAudioTrackChange: (track: AudioTrack) => void; + selectedAudioIndex: number; + onAudioChange: (index: number) => void; subtitleTracks: SubtitleTrack[]; - selectedSubtitleTrack: SubtitleTrack | null; - onSubtitleTrackChange: (track: SubtitleTrack | null) => void; + /** -1 = subtitles off. */ + selectedSubtitleIndex: number; + onSubtitleChange: (index: number) => void; playbackSpeed: number; onPlaybackSpeedChange: (speed: number) => void; } const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; +const ACCENT = "#a855f7"; export const ChromecastSettingsMenu: React.FC = ({ visible, onClose, - item: _item, // Reserved for future use (technical info display) - mediaSources, - selectedMediaSource, - onMediaSourceChange, + versions, + selectedVersionId, + onVersionChange, + qualities, + selectedMaxBitrate, + onQualityChange, audioTracks, - selectedAudioTrack, - onAudioTrackChange, + selectedAudioIndex, + onAudioChange, subtitleTracks, - selectedSubtitleTrack, - onSubtitleTrackChange, + selectedSubtitleIndex, + onSubtitleChange, playbackSpeed, onPlaybackSpeedChange, }) => { @@ -89,6 +101,39 @@ export const ChromecastSettingsMenu: React.FC = ({ ); + const renderRow = ( + key: string | number, + label: string, + sublabel: string | null, + selected: boolean, + onPress: () => void, + ) => ( + { + onPress(); + setExpandedSection(null); + }} + style={{ + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + backgroundColor: selected ? "#2a2a2a" : "transparent", + }} + > + + {label} + {sublabel ? ( + + {sublabel} + + ) : null} + + {selected ? : null} + + ); + return ( = ({ }} onPress={(e) => e.stopPropagation()} > - {/* Header */} = ({ - {/* Quality/Media Source - only show when sources available */} - {mediaSources.length > 0 && + {/* Version — only when the item has more than one MediaSource */} + {versions.length > 1 && renderSectionHeader( - t("casting_player.quality"), - "film-outline", - "quality", + t("casting_player.version"), + "albums-outline", + "version", )} - {mediaSources.length > 0 && expandedSection === "quality" && ( + {versions.length > 1 && expandedSection === "version" && ( - {mediaSources.map((source) => ( - { - onMediaSourceChange(source); - setExpandedSection(null); - }} - style={{ - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 16, - backgroundColor: - selectedMediaSource?.id === source.id - ? "#2a2a2a" - : "transparent", - }} - > - - - {source.name} - - {source.bitrate && ( - - {Math.round(source.bitrate / 1000000)} Mbps - - )} - - {selectedMediaSource?.id === source.id && ( - - )} - - ))} + {versions.map((v) => + renderRow( + v.id, + v.name, + null, + v.id === selectedVersionId, + () => onVersionChange(v.id), + ), + )} )} - {/* Audio Tracks - only show if more than one track */} + {/* Quality (bitrate cap) */} + {renderSectionHeader( + t("casting_player.quality"), + "film-outline", + "quality", + )} + {expandedSection === "quality" && ( + + {qualities.map((q) => + renderRow( + q.key, + q.key, + null, + q.value === selectedMaxBitrate, + () => onQualityChange(q.value), + ), + )} + + )} + + {/* Audio — only when more than one track */} {audioTracks.length > 1 && renderSectionHeader( t("casting_player.audio"), @@ -190,47 +228,21 @@ export const ChromecastSettingsMenu: React.FC = ({ )} {audioTracks.length > 1 && expandedSection === "audio" && ( - {audioTracks.map((track) => ( - { - onAudioTrackChange(track); - setExpandedSection(null); - }} - style={{ - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 16, - backgroundColor: - selectedAudioTrack?.index === track.index - ? "#2a2a2a" - : "transparent", - }} - > - - - {track.displayTitle || - track.language || - t("casting_player.unknown")} - - {track.codec && ( - - {track.codec.toUpperCase()} - - )} - - {selectedAudioTrack?.index === track.index && ( - - )} - - ))} + {audioTracks.map((track) => + renderRow( + track.index, + track.displayTitle || + track.language || + t("casting_player.unknown"), + track.codec ? track.codec.toUpperCase() : null, + track.index === selectedAudioIndex, + () => onAudioChange(track.index), + ), + )} )} - {/* Subtitle Tracks - only show if subtitles available */} + {/* Subtitles */} {subtitleTracks.length > 0 && renderSectionHeader( t("casting_player.subtitles"), @@ -239,71 +251,33 @@ export const ChromecastSettingsMenu: React.FC = ({ )} {subtitleTracks.length > 0 && expandedSection === "subtitles" && ( - { - onSubtitleTrackChange(null); - setExpandedSection(null); - }} - style={{ - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 16, - backgroundColor: - selectedSubtitleTrack === null - ? "#2a2a2a" - : "transparent", - }} - > - - {t("casting_player.none")} - - {selectedSubtitleTrack === null && ( - - )} - - {subtitleTracks.map((track) => ( - { - onSubtitleTrackChange(track); - setExpandedSection(null); - }} - style={{ - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 16, - backgroundColor: - selectedSubtitleTrack?.index === track.index - ? "#2a2a2a" - : "transparent", - }} - > - - - {track.displayTitle || - track.language || - t("casting_player.unknown")} - - {(track.codec || track.isForced) && ( - - {track.codec ? track.codec.toUpperCase() : ""} - {track.isForced && ` • ${t("casting_player.forced")}`} - - )} - - {selectedSubtitleTrack?.index === track.index && ( - - )} - - ))} + {renderRow( + "off", + t("casting_player.none"), + null, + selectedSubtitleIndex < 0, + () => onSubtitleChange(-1), + )} + {subtitleTracks.map((track) => + renderRow( + track.index, + track.displayTitle || + track.language || + t("casting_player.unknown"), + [ + track.codec ? track.codec.toUpperCase() : "", + track.isForced ? t("casting_player.forced") : "", + ] + .filter(Boolean) + .join(" • ") || null, + track.index === selectedSubtitleIndex, + () => onSubtitleChange(track.index), + ), + )} )} - {/* Playback Speed */} + {/* Playback speed */} {renderSectionHeader( t("casting_player.playback_speed"), "speedometer", @@ -311,32 +285,15 @@ export const ChromecastSettingsMenu: React.FC = ({ )} {expandedSection === "speed" && ( - {PLAYBACK_SPEEDS.map((speed) => ( - { - onPlaybackSpeedChange(speed); - setExpandedSection(null); - }} - style={{ - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 16, - backgroundColor: - Math.abs(playbackSpeed - speed) < 0.01 - ? "#2a2a2a" - : "transparent", - }} - > - - {speed === 1 ? t("casting_player.normal") : `${speed}x`} - - {Math.abs(playbackSpeed - speed) < 0.01 && ( - - )} - - ))} + {PLAYBACK_SPEEDS.map((speed) => + renderRow( + speed, + speed === 1 ? t("casting_player.normal") : `${speed}x`, + null, + Math.abs(playbackSpeed - speed) < 0.01, + () => onPlaybackSpeedChange(speed), + ), + )} )} diff --git a/translations/en.json b/translations/en.json index 15458e946..c1b1a28c8 100644 --- a/translations/en.json +++ b/translations/en.json @@ -67,6 +67,7 @@ "chromecast": "Chromecast", "device_name": "Device Name", "playback_settings": "Playback Settings", + "version": "Version", "quality": "Quality", "audio": "Audio", "subtitles": "Subtitles",