mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
feat: Expo 54 (new arch) support + new in-house download module (#1174)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: sarendsen <coding-mosses0z@icloud.com> Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
154788cf91
commit
485dc6eeac
@@ -1,16 +1,12 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
|
||||
@@ -23,10 +19,6 @@ const DropdownView = () => {
|
||||
ControlContext?.mediaSource,
|
||||
];
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [open, setOpen] = useState(false);
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["75%"], []);
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||
useLocalSearchParams<{
|
||||
@@ -39,248 +31,127 @@ const DropdownView = () => {
|
||||
offline: string;
|
||||
}>();
|
||||
|
||||
// Use ref to track playbackPosition without causing re-renders
|
||||
const playbackPositionRef = useRef(playbackPosition);
|
||||
playbackPositionRef.current = playbackPosition;
|
||||
|
||||
const isOffline = offline === "true";
|
||||
|
||||
// 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: item.Id ?? "",
|
||||
itemId: itemIdRef.current ?? "",
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSourceIdRef.current ?? "",
|
||||
bitrateValue: bitrate.toString(),
|
||||
playbackPosition: playbackPosition,
|
||||
playbackPosition: playbackPositionRef.current,
|
||||
}).toString();
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
},
|
||||
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
||||
[audioIndex, subtitleIndex, router],
|
||||
);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
// Create stable identifiers for tracks
|
||||
const subtitleTracksKey = useMemo(
|
||||
() => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
||||
[subtitleTracks],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// 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(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isOffline,
|
||||
bitrateValue,
|
||||
changeBitrate,
|
||||
subtitleTracksKey,
|
||||
audioTracksKey,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
// 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>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
bottomSheetModalRef.current?.present();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) bottomSheetModalRef.current?.present();
|
||||
else bottomSheetModalRef.current?.dismiss();
|
||||
}, [open]);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
onPress={handleOpen}
|
||||
>
|
||||
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='mt-2 mb-8'
|
||||
style={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-2xl mb-6'>Playback Options</Text>
|
||||
|
||||
{/* Quality Section */}
|
||||
{!isOffline && (
|
||||
<View className='mb-6'>
|
||||
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
|
||||
Quality
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='flex flex-col rounded-xl overflow-hidden'
|
||||
>
|
||||
{BITRATES?.map((bitrate, idx: number) => (
|
||||
<View key={`quality-item-${idx}`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
changeBitrate(bitrate.value?.toString() ?? "");
|
||||
setTimeout(() => handleClose(), 250);
|
||||
}}
|
||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{bitrate.key}</Text>
|
||||
{bitrateValue === (bitrate.value?.toString() ?? "") ? (
|
||||
<Ionicons
|
||||
name='radio-button-on'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
) : (
|
||||
<Ionicons
|
||||
name='radio-button-off'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{idx < BITRATES.length - 1 && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Subtitle Section */}
|
||||
<View className='mb-6'>
|
||||
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
|
||||
Subtitles
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='flex flex-col rounded-xl overflow-hidden'
|
||||
>
|
||||
{subtitleTracks?.map((sub, idx: number) => (
|
||||
<View key={`subtitle-item-${idx}`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
sub.setTrack();
|
||||
setTimeout(() => handleClose(), 250);
|
||||
}}
|
||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{sub.name}</Text>
|
||||
{subtitleIndex === sub.index.toString() ? (
|
||||
<Ionicons
|
||||
name='radio-button-on'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
) : (
|
||||
<Ionicons
|
||||
name='radio-button-off'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{idx < (subtitleTracks?.length ?? 0) - 1 && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Audio Section */}
|
||||
{(audioTracks?.length ?? 0) > 0 && (
|
||||
<View className='mb-6'>
|
||||
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
|
||||
Audio
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='flex flex-col rounded-xl overflow-hidden'
|
||||
>
|
||||
{audioTracks?.map((track, idx: number) => (
|
||||
<View key={`audio-item-${idx}`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
track.setTrack();
|
||||
setTimeout(() => handleClose(), 250);
|
||||
}}
|
||||
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{track.name}</Text>
|
||||
{audioIndex === track.index.toString() ? (
|
||||
<Ionicons
|
||||
name='radio-button-on'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
) : (
|
||||
<Ionicons
|
||||
name='radio-button-off'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{idx < (audioTracks?.length ?? 0) - 1 && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
<PlatformDropdown
|
||||
title='Playback Options'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user