Files
streamyfin/components/video-player/controls/dropdown/DropdownView.tsx
2026-01-12 21:55:32 +01:00

230 lines
6.8 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import useRouter from "@/hooks/useAppRouter";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayerContext } from "../contexts/PlayerContext";
import { useVideoContext } from "../contexts/VideoContext";
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
// Subtitle size presets (stored as scale * 100, so 1.0 = 100)
const SUBTITLE_SIZE_PRESETS = [
{ label: "0.5", value: 50 },
{ label: "0.6", value: 60 },
{ label: "0.7", value: 70 },
{ label: "0.8", value: 80 },
{ label: "0.9", value: 90 },
{ label: "1.0", value: 100 },
{ label: "1.1", value: 110 },
{ label: "1.2", value: 120 },
] as const;
interface DropdownViewProps {
playbackSpeed?: number;
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
showTechnicalInfo?: boolean;
onToggleTechnicalInfo?: () => void;
}
const DropdownView = ({
playbackSpeed = 1.0,
setPlaybackSpeed,
showTechnicalInfo = false,
onToggleTechnicalInfo,
}: DropdownViewProps) => {
const { subtitleTracks, audioTracks } = useVideoContext();
const { item, mediaSource } = usePlayerContext();
const { settings, updateSettings } = useSettings();
const router = useRouter();
const isOffline = useOfflineMode();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
playbackPosition: string;
}>();
// Use ref to track playbackPosition without causing re-renders
const playbackPositionRef = useRef(playbackPosition);
playbackPositionRef.current = playbackPosition;
// Stabilize IDs to prevent unnecessary recalculations
const itemIdRef = useRef(item.Id);
const mediaSourceIdRef = useRef(mediaSource?.Id);
itemIdRef.current = item.Id;
mediaSourceIdRef.current = mediaSource?.Id;
const changeBitrate = useCallback(
(bitrate: string) => {
const queryParams = new URLSearchParams({
itemId: itemIdRef.current ?? "",
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSourceIdRef.current ?? "",
bitrateValue: bitrate.toString(),
playbackPosition: playbackPositionRef.current,
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
},
[audioIndex, subtitleIndex, router],
);
// Create stable identifiers for tracks
const subtitleTracksKey = useMemo(
() => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
[subtitleTracks],
);
const audioTracksKey = useMemo(
() => audioTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
[audioTracks],
);
// Transform sections into OptionGroup format
const optionGroups = useMemo<OptionGroup[]>(() => {
const groups: OptionGroup[] = [];
// Quality Section
if (!isOffline) {
groups.push({
title: "Quality",
options:
BITRATES?.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate.value?.toString() ?? "",
selected: bitrateValue === (bitrate.value?.toString() ?? ""),
onPress: () => changeBitrate(bitrate.value?.toString() ?? ""),
})) || [],
});
}
// Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({
title: "Subtitles",
options: subtitleTracks.map((sub) => ({
type: "radio" as const,
label: sub.name,
value: sub.index.toString(),
selected: subtitleIndex === sub.index.toString(),
onPress: () => sub.setTrack(),
})),
});
// Subtitle Size Section
groups.push({
title: "Subtitle Size",
options: SUBTITLE_SIZE_PRESETS.map((preset) => ({
type: "radio" as const,
label: preset.label,
value: preset.value.toString(),
selected: settings.subtitleSize === preset.value,
onPress: () => updateSettings({ subtitleSize: preset.value }),
})),
});
}
// Audio Section
if (audioTracks && audioTracks.length > 0) {
groups.push({
title: "Audio",
options: audioTracks.map((track) => ({
type: "radio" as const,
label: track.name,
value: track.index.toString(),
selected: audioIndex === track.index.toString(),
onPress: () => track.setTrack(),
})),
});
}
// Speed Section
if (setPlaybackSpeed) {
groups.push({
title: "Speed",
options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const,
label: speed.label,
value: speed.value.toString(),
selected: playbackSpeed === speed.value,
onPress: () => setPlaybackSpeed(speed.value, PlaybackSpeedScope.All),
})),
});
}
// Technical Info (at bottom)
if (onToggleTechnicalInfo) {
groups.push({
options: [
{
type: "action" as const,
label: showTechnicalInfo
? "Hide Technical Info"
: "Show Technical Info",
onPress: onToggleTechnicalInfo,
},
],
});
}
return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isOffline,
bitrateValue,
changeBitrate,
subtitleTracksKey,
audioTracksKey,
subtitleIndex,
audioIndex,
settings.subtitleSize,
updateSettings,
playbackSpeed,
setPlaybackSpeed,
showTechnicalInfo,
onToggleTechnicalInfo,
// Note: subtitleTracks and audioTracks are intentionally excluded
// because we use subtitleTracksKey and audioTracksKey for stability
]);
// Memoize the trigger to prevent re-renders
const trigger = useMemo(
() => (
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
</View>
),
[],
);
// Hide on TV platforms
if (Platform.isTV) return null;
return (
<PlatformDropdown
title='Playback Options'
groups={optionGroups}
trigger={trigger}
expoUIConfig={{}}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};
export default DropdownView;