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,
} 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<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);
// 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<boolean> => {
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() {
<ChromecastSettingsMenu
visible={showSettings}
onClose={() => 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);

View File

@@ -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<ChromecastSettingsMenuProps> = ({
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<ChromecastSettingsMenuProps> = ({
</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 (
<Modal
visible={visible}
@@ -114,7 +159,6 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View
style={{
flexDirection: "row",
@@ -134,54 +178,48 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
</View>
<ScrollView>
{/* 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" && (
<View style={{ paddingVertical: 8 }}>
{mediaSources.map((source) => (
<Pressable
key={source.id}
onPress={() => {
onMediaSourceChange(source);
setExpandedSection(null);
}}
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>
))}
{versions.map((v) =>
renderRow(
v.id,
v.name,
null,
v.id === selectedVersionId,
() => onVersionChange(v.id),
),
)}
</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 &&
renderSectionHeader(
t("casting_player.audio"),
@@ -190,47 +228,21 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)}
{audioTracks.length > 1 && expandedSection === "audio" && (
<View style={{ paddingVertical: 8 }}>
{audioTracks.map((track) => (
<Pressable
key={track.index}
onPress={() => {
onAudioTrackChange(track);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
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>
))}
{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),
),
)}
</View>
)}
{/* 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<ChromecastSettingsMenuProps> = ({
)}
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
<View style={{ paddingVertical: 8 }}>
<Pressable
onPress={() => {
onSubtitleTrackChange(null);
setExpandedSection(null);
}}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
backgroundColor:
selectedSubtitleTrack === null
? "#2a2a2a"
: "transparent",
}}
>
<Text style={{ color: "white", fontSize: 15 }}>
{t("casting_player.none")}
</Text>
{selectedSubtitleTrack === null && (
<Ionicons name='checkmark' size={20} color='#a855f7' />
)}
</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>
))}
{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),
),
)}
</View>
)}
{/* Playback Speed */}
{/* Playback speed */}
{renderSectionHeader(
t("casting_player.playback_speed"),
"speedometer",
@@ -311,32 +285,15 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)}
{expandedSection === "speed" && (
<View style={{ paddingVertical: 8 }}>
{PLAYBACK_SPEEDS.map((speed) => (
<Pressable
key={speed}
onPress={() => {
onPlaybackSpeedChange(speed);
setExpandedSection(null);
}}
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>
))}
{PLAYBACK_SPEEDS.map((speed) =>
renderRow(
speed,
speed === 1 ? t("casting_player.normal") : `${speed}x`,
null,
Math.abs(playbackSpeed - speed) < 0.01,
() => onPlaybackSpeedChange(speed),
),
)}
</View>
)}
</ScrollView>

View File

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