feat(casting): reliable track switching with CastSelection truth

This commit is contained in:
Uruk
2026-05-21 23:58:22 +02:00
parent 6e513b8f9e
commit 88d96603e4
3 changed files with 282 additions and 390 deletions

View File

@@ -35,15 +35,18 @@ import Animated, {
withSpring, withSpring,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES } from "@/components/BitrateSelector";
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet"; import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList"; import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu"; import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments"; import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useCasting } from "@/hooks/useCasting"; import { useCasting } from "@/hooks/useCasting";
import { useCastSelection } from "@/hooks/useCastSelection";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { detectCapabilities } from "@/utils/casting/capabilities";
import { loadCastMedia } from "@/utils/casting/castLoad"; import { loadCastMedia } from "@/utils/casting/castLoad";
import { import {
calculateEndingTime, calculateEndingTime,
@@ -52,6 +55,8 @@ import {
getPosterUrl, getPosterUrl,
truncateTitle, truncateTitle,
} from "@/utils/casting/helpers"; } from "@/utils/casting/helpers";
import { resolveSelection } from "@/utils/casting/selection";
import type { CastSelection } from "@/utils/casting/types";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function CastingPlayerScreen() { export default function CastingPlayerScreen() {
@@ -238,94 +243,40 @@ export default function CastingPlayerScreen() {
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null); const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null); const [seasonData, setSeasonData] = useState<BaseItemDto | null>(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); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1);
// Initialize track selection from server defaults when item data arrives // Reload the cast stream with a full selection; resolves true on success.
useEffect(() => { const reloadWithSelection = useCallback(
if (!fetchedItem) return; async (selection: CastSelection): Promise<boolean> => {
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;
}) => {
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) { if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
console.warn("[Casting Player] Cannot reload - missing required data"); console.warn("[Casting Player] Cannot reload - missing required data");
return; return false;
} }
const currentPosition = mediaStatus?.streamPosition ?? 0;
try { const result = await loadCastMedia({
const currentPosition = mediaStatus?.streamPosition ?? 0; client: remoteMediaClient,
device: castDevice,
let resolvedSubtitleIndex: number | undefined; api,
if (options.subtitleIndex === undefined) { item: currentItem,
resolvedSubtitleIndex = selectedSubtitleTrackIndex ?? undefined; userId: user.Id,
} else if (options.subtitleIndex === null) { profileMode: settings.chromecastProfile,
resolvedSubtitleIndex = -1; maxBitrateSetting: settings.chromecastMaxBitrate,
} else { options: {
resolvedSubtitleIndex = options.subtitleIndex; mediaSourceId: selection.mediaSourceId,
} audioStreamIndex: selection.audioStreamIndex,
subtitleStreamIndex: selection.subtitleStreamIndex,
const result = await loadCastMedia({ maxBitrate: selection.maxBitrate,
client: remoteMediaClient, startPositionMs: currentPosition * 1000,
device: castDevice, },
api, });
item: currentItem, if (!result.ok) {
userId: user.Id, console.error(
profileMode: settings.chromecastProfile, "[Casting Player] Failed to reload stream:",
maxBitrateSetting: settings.chromecastMaxBitrate, result.error,
options: { );
audioStreamIndex: return false;
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);
} }
return true;
}, },
[ [
api, api,
@@ -336,11 +287,15 @@ export default function CastingPlayerScreen() {
mediaStatus?.streamPosition, mediaStatus?.streamPosition,
settings.chromecastProfile, settings.chromecastProfile,
settings.chromecastMaxBitrate, settings.chromecastMaxBitrate,
selectedAudioTrackIndex,
selectedSubtitleTrackIndex,
], ],
); );
const { currentSelection, applySelection } = useCastSelection({
currentItem,
mediaStatus,
reload: reloadWithSelection,
});
// Load a different episode on the Chromecast // Load a different episode on the Chromecast
const loadEpisode = useCallback( const loadEpisode = useCallback(
async (episode: BaseItemDto) => { async (episode: BaseItemDto) => {
@@ -368,9 +323,6 @@ export default function CastingPlayerScreen() {
); );
return; return;
} }
setSelectedAudioTrackIndex(null);
setSelectedSubtitleTrackIndex(null);
} catch (error) { } catch (error) {
console.error("[Casting Player] Failed to load episode:", error); console.error("[Casting Player] Failed to load episode:", error);
} }
@@ -412,87 +364,86 @@ export default function CastingPlayerScreen() {
fetchSeasonData(); fetchSeasonData();
}, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]); }, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]);
const availableAudioTracks = useMemo(() => { // The MediaSource currently selected, for deriving its tracks.
if (!currentItem?.MediaStreams) return []; const selectedSource = useMemo(
() =>
currentItem?.MediaSources?.find(
(s) => s.Id === currentSelection?.mediaSourceId,
) ??
currentItem?.MediaSources?.[0] ??
null,
[currentItem?.MediaSources, currentSelection?.mediaSourceId],
);
return currentItem.MediaStreams.filter( // Real alternate versions (multi-version items).
(stream) => stream.Type === "Audio", const availableVersions = useMemo(
).map((stream) => ({ () =>
index: stream.Index ?? 0, (currentItem?.MediaSources ?? []).map((s, i) => ({
language: stream.Language || "Unknown", id: s.Id ?? `source-${i}`,
displayTitle: name: s.Name || `${t("casting_player.version")} ${i + 1}`,
stream.DisplayTitle || })),
`${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(), [currentItem?.MediaSources, t],
codec: stream.Codec || "Unknown", );
channels: stream.Channels,
bitrate: stream.BitRate, // Quality tiers from the shared ladder, capped to BOTH the device's
})); // capability and the media's own bitrate — a tier above either ceiling
}, [currentItem?.MediaStreams]); // 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(() => { const availableSubtitleTracks = useMemo(() => {
if (!currentItem?.MediaStreams) return []; const streams = selectedSource?.MediaStreams ?? currentItem?.MediaStreams;
if (!streams) return [];
return currentItem.MediaStreams.filter( return streams
(stream) => stream.Type === "Subtitle", .filter((stream) => stream.Type === "Subtitle")
).map((stream) => ({ .map((stream) => ({
index: stream.Index ?? 0, index: stream.Index ?? 0,
language: stream.Language || "Unknown", language: stream.Language || "Unknown",
displayTitle: displayTitle:
stream.DisplayTitle || stream.DisplayTitle ||
[ [
stream.Language || "Unknown", stream.Language || "Unknown",
stream.IsForced ? " (Forced)" : "", stream.IsForced ? " (Forced)" : "",
stream.Title ? ` - ${stream.Title}` : "", stream.Title ? ` - ${stream.Title}` : "",
].join(""), ].join(""),
codec: stream.Codec || "Unknown", codec: stream.Codec || "Unknown",
isForced: stream.IsForced || false, isForced: stream.IsForced || false,
isExternal: stream.IsExternal || false, isExternal: stream.IsExternal || false,
})); }));
}, [currentItem?.MediaStreams]); }, [selectedSource, 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]);
// Fetch episodes for TV shows // Fetch episodes for TV shows
useEffect(() => { useEffect(() => {
@@ -1430,44 +1381,27 @@ export default function CastingPlayerScreen() {
<ChromecastSettingsMenu <ChromecastSettingsMenu
visible={showSettings} visible={showSettings}
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
item={currentItem} versions={availableVersions}
mediaSources={availableMediaSources.filter((source) => { selectedVersionId={currentSelection?.mediaSourceId ?? ""}
const currentBitrate = onVersionChange={(id) => {
availableMediaSources[0]?.bitrate || Number.POSITIVE_INFINITY; if (!currentItem) return;
return (source.bitrate || 0) <= currentBitrate; applySelection(
})} resolveSelection(currentItem, { mediaSourceId: id }),
selectedMediaSource={availableMediaSources[0] || null} );
onMediaSourceChange={(source) => {
reloadWithSettings({ bitrateValue: source.bitrate });
}} }}
qualities={availableQualities}
selectedMaxBitrate={currentSelection?.maxBitrate}
onQualityChange={(value) => applySelection({ maxBitrate: value })}
audioTracks={availableAudioTracks} audioTracks={availableAudioTracks}
selectedAudioTrack={ selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1}
selectedAudioTrackIndex === null onAudioChange={(index) =>
? availableAudioTracks[0] || null applySelection({ audioStreamIndex: index })
: availableAudioTracks.find(
(t) => t.index === selectedAudioTrackIndex,
) || null
} }
onAudioTrackChange={(track) => {
setSelectedAudioTrackIndex(track.index);
// Reload stream with new audio track
reloadWithSettings({ audioIndex: track.index });
}}
subtitleTracks={availableSubtitleTracks} subtitleTracks={availableSubtitleTracks}
selectedSubtitleTrack={ selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1}
selectedSubtitleTrackIndex == null || onSubtitleChange={(index) =>
selectedSubtitleTrackIndex < 0 applySelection({ subtitleStreamIndex: index })
? null
: availableSubtitleTracks.find(
(t) => t.index === selectedSubtitleTrackIndex,
) || null
} }
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} playbackSpeed={currentPlaybackSpeed}
onPlaybackSpeedChange={(speed) => { onPlaybackSpeedChange={(speed) => {
setCurrentPlaybackSpeed(speed); setCurrentPlaybackSpeed(speed);

View File

@@ -1,53 +1,65 @@
/** /**
* Chromecast Settings Menu * 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 { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, View } from "react-native"; import { Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import type { import type { AudioTrack, SubtitleTrack } from "@/utils/casting/types";
AudioTrack,
MediaSource, export interface VersionOption {
SubtitleTrack, id: string;
} from "@/utils/casting/types"; name: string;
}
export interface QualityOption {
key: string;
value: number | undefined;
}
interface ChromecastSettingsMenuProps { interface ChromecastSettingsMenuProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
item: BaseItemDto; versions: VersionOption[];
mediaSources: MediaSource[]; selectedVersionId: string;
selectedMediaSource: MediaSource | null; onVersionChange: (id: string) => void;
onMediaSourceChange: (source: MediaSource) => void; qualities: QualityOption[];
selectedMaxBitrate: number | undefined;
onQualityChange: (value: number | undefined) => void;
audioTracks: AudioTrack[]; audioTracks: AudioTrack[];
selectedAudioTrack: AudioTrack | null; selectedAudioIndex: number;
onAudioTrackChange: (track: AudioTrack) => void; onAudioChange: (index: number) => void;
subtitleTracks: SubtitleTrack[]; subtitleTracks: SubtitleTrack[];
selectedSubtitleTrack: SubtitleTrack | null; /** -1 = subtitles off. */
onSubtitleTrackChange: (track: SubtitleTrack | null) => void; selectedSubtitleIndex: number;
onSubtitleChange: (index: number) => void;
playbackSpeed: number; playbackSpeed: number;
onPlaybackSpeedChange: (speed: number) => void; onPlaybackSpeedChange: (speed: number) => void;
} }
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; 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<ChromecastSettingsMenuProps> = ({ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
visible, visible,
onClose, onClose,
item: _item, // Reserved for future use (technical info display) versions,
mediaSources, selectedVersionId,
selectedMediaSource, onVersionChange,
onMediaSourceChange, qualities,
selectedMaxBitrate,
onQualityChange,
audioTracks, audioTracks,
selectedAudioTrack, selectedAudioIndex,
onAudioTrackChange, onAudioChange,
subtitleTracks, subtitleTracks,
selectedSubtitleTrack, selectedSubtitleIndex,
onSubtitleTrackChange, onSubtitleChange,
playbackSpeed, playbackSpeed,
onPlaybackSpeedChange, onPlaybackSpeedChange,
}) => { }) => {
@@ -89,6 +101,39 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
</Pressable> </Pressable>
); );
const renderRow = (
key: string | number,
label: string,
sublabel: string | null,
selected: boolean,
onPress: () => void,
) => (
<Pressable
key={key}
onPress={() => {
onPress();
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor: selected ? "#2a2a2a" : "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>{label}</Text>
{sublabel ? (
<Text style={{ color: "#999", fontSize: 13, marginTop: 2 }}>
{sublabel}
</Text>
) : null}
</View>
{selected ? <Ionicons name='checkmark' size={20} color={ACCENT} /> : null}
</Pressable>
);
return ( return (
<Modal <Modal
visible={visible} visible={visible}
@@ -114,7 +159,6 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
}} }}
onPress={(e) => e.stopPropagation()} onPress={(e) => e.stopPropagation()}
> >
{/* Header */}
<View <View
style={{ style={{
flexDirection: "row", flexDirection: "row",
@@ -134,54 +178,48 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
</View> </View>
<ScrollView> <ScrollView>
{/* Quality/Media Source - only show when sources available */} {/* Version — only when the item has more than one MediaSource */}
{mediaSources.length > 0 && {versions.length > 1 &&
renderSectionHeader( renderSectionHeader(
t("casting_player.quality"), t("casting_player.version"),
"film-outline", "albums-outline",
"quality", "version",
)} )}
{mediaSources.length > 0 && expandedSection === "quality" && ( {versions.length > 1 && expandedSection === "version" && (
<View style={{ paddingVertical: 8 }}> <View style={{ paddingVertical: 8 }}>
{mediaSources.map((source) => ( {versions.map((v) =>
<Pressable renderRow(
key={source.id} v.id,
onPress={() => { v.name,
onMediaSourceChange(source); null,
setExpandedSection(null); v.id === selectedVersionId,
}} () => onVersionChange(v.id),
style={{ ),
flexDirection: "row", )}
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedMediaSource?.id === source.id
? "#2a2a2a"
: "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{source.name}
</Text>
{source.bitrate && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{Math.round(source.bitrate / 1000000)} Mbps
</Text>
)}
</View>
{selectedMediaSource?.id === source.id && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View> </View>
)} )}
{/* Audio Tracks - only show if more than one track */} {/* Quality (bitrate cap) */}
{renderSectionHeader(
t("casting_player.quality"),
"film-outline",
"quality",
)}
{expandedSection === "quality" && (
<View style={{ paddingVertical: 8 }}>
{qualities.map((q) =>
renderRow(
q.key,
q.key,
null,
q.value === selectedMaxBitrate,
() => onQualityChange(q.value),
),
)}
</View>
)}
{/* Audio — only when more than one track */}
{audioTracks.length > 1 && {audioTracks.length > 1 &&
renderSectionHeader( renderSectionHeader(
t("casting_player.audio"), t("casting_player.audio"),
@@ -190,47 +228,21 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)} )}
{audioTracks.length > 1 && expandedSection === "audio" && ( {audioTracks.length > 1 && expandedSection === "audio" && (
<View style={{ paddingVertical: 8 }}> <View style={{ paddingVertical: 8 }}>
{audioTracks.map((track) => ( {audioTracks.map((track) =>
<Pressable renderRow(
key={track.index} track.index,
onPress={() => { track.displayTitle ||
onAudioTrackChange(track); track.language ||
setExpandedSection(null); t("casting_player.unknown"),
}} track.codec ? track.codec.toUpperCase() : null,
style={{ track.index === selectedAudioIndex,
flexDirection: "row", () => onAudioChange(track.index),
justifyContent: "space-between", ),
alignItems: "center", )}
padding: 16,
backgroundColor:
selectedAudioTrack?.index === track.index
? "#2a2a2a"
: "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text>
{track.codec && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{track.codec.toUpperCase()}
</Text>
)}
</View>
{selectedAudioTrack?.index === track.index && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View> </View>
)} )}
{/* Subtitle Tracks - only show if subtitles available */} {/* Subtitles */}
{subtitleTracks.length > 0 && {subtitleTracks.length > 0 &&
renderSectionHeader( renderSectionHeader(
t("casting_player.subtitles"), t("casting_player.subtitles"),
@@ -239,71 +251,33 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)} )}
{subtitleTracks.length > 0 && expandedSection === "subtitles" && ( {subtitleTracks.length > 0 && expandedSection === "subtitles" && (
<View style={{ paddingVertical: 8 }}> <View style={{ paddingVertical: 8 }}>
<Pressable {renderRow(
onPress={() => { "off",
onSubtitleTrackChange(null); t("casting_player.none"),
setExpandedSection(null); null,
}} selectedSubtitleIndex < 0,
style={{ () => onSubtitleChange(-1),
flexDirection: "row", )}
justifyContent: "space-between", {subtitleTracks.map((track) =>
alignItems: "center", renderRow(
padding: 16, track.index,
backgroundColor: track.displayTitle ||
selectedSubtitleTrack === null track.language ||
? "#2a2a2a" t("casting_player.unknown"),
: "transparent", [
}} track.codec ? track.codec.toUpperCase() : "",
> track.isForced ? t("casting_player.forced") : "",
<Text style={{ color: "white", fontSize: 15 }}> ]
{t("casting_player.none")} .filter(Boolean)
</Text> .join(" • ") || null,
{selectedSubtitleTrack === null && ( track.index === selectedSubtitleIndex,
<Ionicons name='checkmark' size={20} color='#a855f7' /> () => onSubtitleChange(track.index),
)} ),
</Pressable> )}
{subtitleTracks.map((track) => (
<Pressable
key={track.index}
onPress={() => {
onSubtitleTrackChange(track);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedSubtitleTrack?.index === track.index
? "#2a2a2a"
: "transparent",
}}
>
<View>
<Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text>
{(track.codec || track.isForced) && (
<Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
>
{track.codec ? track.codec.toUpperCase() : ""}
{track.isForced && `${t("casting_player.forced")}`}
</Text>
)}
</View>
{selectedSubtitleTrack?.index === track.index && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View> </View>
)} )}
{/* Playback Speed */} {/* Playback speed */}
{renderSectionHeader( {renderSectionHeader(
t("casting_player.playback_speed"), t("casting_player.playback_speed"),
"speedometer", "speedometer",
@@ -311,32 +285,15 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)} )}
{expandedSection === "speed" && ( {expandedSection === "speed" && (
<View style={{ paddingVertical: 8 }}> <View style={{ paddingVertical: 8 }}>
{PLAYBACK_SPEEDS.map((speed) => ( {PLAYBACK_SPEEDS.map((speed) =>
<Pressable renderRow(
key={speed} speed,
onPress={() => { speed === 1 ? t("casting_player.normal") : `${speed}x`,
onPlaybackSpeedChange(speed); null,
setExpandedSection(null); Math.abs(playbackSpeed - speed) < 0.01,
}} () => onPlaybackSpeedChange(speed),
style={{ ),
flexDirection: "row", )}
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
Math.abs(playbackSpeed - speed) < 0.01
? "#2a2a2a"
: "transparent",
}}
>
<Text style={{ color: "white", fontSize: 15 }}>
{speed === 1 ? t("casting_player.normal") : `${speed}x`}
</Text>
{Math.abs(playbackSpeed - speed) < 0.01 && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</Pressable>
))}
</View> </View>
)} )}
</ScrollView> </ScrollView>

View File

@@ -67,6 +67,7 @@
"chromecast": "Chromecast", "chromecast": "Chromecast",
"device_name": "Device Name", "device_name": "Device Name",
"playback_settings": "Playback Settings", "playback_settings": "Playback Settings",
"version": "Version",
"quality": "Quality", "quality": "Quality",
"audio": "Audio", "audio": "Audio",
"subtitles": "Subtitles", "subtitles": "Subtitles",