mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 03:58:36 +01:00
feat(casting): reliable track switching with CastSelection truth
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"chromecast": "Chromecast",
|
||||
"device_name": "Device Name",
|
||||
"playback_settings": "Playback Settings",
|
||||
"version": "Version",
|
||||
"quality": "Quality",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subtitles",
|
||||
|
||||
Reference in New Issue
Block a user