mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 12:08:37 +01:00
feat(casting): reliable track switching with CastSelection truth
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user