mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-25 00:06:39 +01:00
wip
This commit is contained in:
@@ -3,8 +3,24 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useCallback, useEffect } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Pressable,
|
||||
Animated as RNAnimated,
|
||||
Easing as RNEasing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -39,11 +55,430 @@ interface Props {
|
||||
seek: (ticks: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
audioIndex?: number;
|
||||
subtitleIndex?: number;
|
||||
onAudioIndexChange?: (index: number) => void;
|
||||
onSubtitleIndexChange?: (index: number) => void;
|
||||
}
|
||||
|
||||
const TV_SEEKBAR_HEIGHT = 16;
|
||||
const TV_AUTO_HIDE_TIMEOUT = 5000;
|
||||
|
||||
// Option item type for TV selector
|
||||
type TVOptionItem<T> = {
|
||||
label: string;
|
||||
value: T;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
// TV Option Selector - Bottom sheet with horizontal scrolling
|
||||
const TVOptionSelector = <T,>({
|
||||
visible,
|
||||
title,
|
||||
options,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
options: TVOptionItem<T>[];
|
||||
onSelect: (value: T) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const initialSelectedIndex = useMemo(() => {
|
||||
const idx = options.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [options]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View style={selectorStyles.overlay}>
|
||||
<BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}>
|
||||
<View style={selectorStyles.content}>
|
||||
<Text style={selectorStyles.title}>{title}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={selectorStyles.scrollView}
|
||||
contentContainerStyle={selectorStyles.scrollContent}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={index}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Option card for horizontal selector
|
||||
const TVOptionCard: FC<{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
RNAnimated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: RNEasing.out(RNEasing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
selectorStyles.card,
|
||||
{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: selected
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
selectorStyles.cardText,
|
||||
{ color: focused ? "#000" : "#fff" },
|
||||
(focused || selected) && { fontWeight: "600" },
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{selected && !focused && (
|
||||
<View style={selectorStyles.checkmark}>
|
||||
<Ionicons
|
||||
name='checkmark'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.8)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</RNAnimated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Settings panel with tabs for Audio and Subtitles
|
||||
const TVSettingsPanel: FC<{
|
||||
visible: boolean;
|
||||
audioOptions: TVOptionItem<number>[];
|
||||
subtitleOptions: TVOptionItem<number>[];
|
||||
onAudioSelect: (value: number) => void;
|
||||
onSubtitleSelect: (value: number) => void;
|
||||
onClose: () => void;
|
||||
t: (key: string) => string;
|
||||
}> = ({
|
||||
visible,
|
||||
audioOptions,
|
||||
subtitleOptions,
|
||||
onAudioSelect,
|
||||
onSubtitleSelect,
|
||||
onClose,
|
||||
t,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<"audio" | "subtitle">("audio");
|
||||
|
||||
const currentOptions = activeTab === "audio" ? audioOptions : subtitleOptions;
|
||||
const currentOnSelect =
|
||||
activeTab === "audio" ? onAudioSelect : onSubtitleSelect;
|
||||
|
||||
const initialSelectedIndex = useMemo(() => {
|
||||
const idx = currentOptions.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [currentOptions]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View style={selectorStyles.overlay}>
|
||||
<BlurView intensity={80} tint='dark' style={selectorStyles.blurContainer}>
|
||||
<View style={selectorStyles.content}>
|
||||
{/* Tab buttons - no preferred focus, navigate here via up from options */}
|
||||
<View style={selectorStyles.tabRow}>
|
||||
{audioOptions.length > 0 && (
|
||||
<TVSettingsTab
|
||||
label={t("item_card.audio")}
|
||||
active={activeTab === "audio"}
|
||||
onPress={() => setActiveTab("audio")}
|
||||
/>
|
||||
)}
|
||||
{subtitleOptions.length > 0 && (
|
||||
<TVSettingsTab
|
||||
label={t("item_card.subtitles")}
|
||||
active={activeTab === "subtitle"}
|
||||
onPress={() => setActiveTab("subtitle")}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Options - first selected option gets preferred focus */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={selectorStyles.scrollView}
|
||||
contentContainerStyle={selectorStyles.scrollContent}
|
||||
>
|
||||
{currentOptions.map((option, index) => (
|
||||
<TVOptionCard
|
||||
key={`${activeTab}-${index}`}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
onPress={() => {
|
||||
currentOnSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Tab button for settings panel
|
||||
const TVSettingsTab: FC<{
|
||||
label: string;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}> = ({ label, active, onPress, hasTVPreferredFocus }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
RNAnimated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 120,
|
||||
easing: RNEasing.out(RNEasing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
selectorStyles.tabButton,
|
||||
{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: active
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "transparent",
|
||||
borderBottomColor: active ? "#fff" : "transparent",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
selectorStyles.tabText,
|
||||
{ color: focused ? "#000" : "#fff" },
|
||||
(focused || active) && { fontWeight: "600" },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</RNAnimated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Button to open option selector (kept for potential future use)
|
||||
const _TVControlButton: FC<{
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
onFocusChange?: (focused: boolean) => void;
|
||||
}> = ({ icon, label, onPress, disabled, onFocusChange }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
RNAnimated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 120,
|
||||
easing: RNEasing.out(RNEasing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.08);
|
||||
onFocusChange?.(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
onFocusChange?.(false);
|
||||
}}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
selectorStyles.controlButton,
|
||||
{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused
|
||||
? "rgba(255,255,255,0.25)"
|
||||
: "rgba(255,255,255,0.15)",
|
||||
borderColor: focused ? "rgba(255,255,255,0.6)" : "transparent",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={20}
|
||||
color='#fff'
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
selectorStyles.controlButtonText,
|
||||
{ color: "#fff", fontWeight: focused ? "600" : "500" },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</RNAnimated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const selectorStyles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
scrollView: {
|
||||
overflow: "visible",
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
},
|
||||
card: {
|
||||
width: 180,
|
||||
height: 80,
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
cardText: {
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
},
|
||||
checkmark: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
},
|
||||
controlButton: {
|
||||
borderRadius: 10,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderWidth: 2,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
controlButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
tabRow: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 16,
|
||||
gap: 24,
|
||||
},
|
||||
tabButton: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
borderBottomWidth: 2,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export const Controls: FC<Props> = ({
|
||||
item,
|
||||
seek,
|
||||
@@ -56,8 +491,92 @@ export const Controls: FC<Props> = ({
|
||||
cacheProgress,
|
||||
showControls,
|
||||
setShowControls,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
onAudioIndexChange,
|
||||
onSubtitleIndexChange,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Modal state for option selectors
|
||||
// "settings" shows the settings panel, "audio"/"subtitle" for direct selection
|
||||
type ModalType = "settings" | "audio" | "subtitle" | null;
|
||||
const [openModal, setOpenModal] = useState<ModalType>(null);
|
||||
const isModalOpen = openModal !== null;
|
||||
|
||||
// Handle swipe up to open settings panel
|
||||
const handleSwipeUp = useCallback(() => {
|
||||
if (!isModalOpen) {
|
||||
setOpenModal("settings");
|
||||
}
|
||||
}, [isModalOpen]);
|
||||
|
||||
// Get available audio tracks
|
||||
const audioTracks = useMemo(() => {
|
||||
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
|
||||
}, [mediaSource]);
|
||||
|
||||
// Get available subtitle tracks
|
||||
const subtitleTracks = useMemo(() => {
|
||||
return (
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? []
|
||||
);
|
||||
}, [mediaSource]);
|
||||
|
||||
// Audio options for selector
|
||||
const audioOptions = useMemo(() => {
|
||||
return audioTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||
value: track.Index!,
|
||||
selected: track.Index === audioIndex,
|
||||
}));
|
||||
}, [audioTracks, audioIndex]);
|
||||
|
||||
// Subtitle options for selector (with "None" option)
|
||||
const subtitleOptions = useMemo(() => {
|
||||
const noneOption = {
|
||||
label: t("item_card.subtitles.none"),
|
||||
value: -1,
|
||||
selected: subtitleIndex === -1,
|
||||
};
|
||||
const trackOptions = subtitleTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||
value: track.Index!,
|
||||
selected: track.Index === subtitleIndex,
|
||||
}));
|
||||
return [noneOption, ...trackOptions];
|
||||
}, [subtitleTracks, subtitleIndex, t]);
|
||||
|
||||
// Get display labels for buttons
|
||||
const _selectedAudioLabel = useMemo(() => {
|
||||
const track = audioTracks.find((t) => t.Index === audioIndex);
|
||||
return track?.DisplayTitle || track?.Language || t("item_card.audio");
|
||||
}, [audioTracks, audioIndex, t]);
|
||||
|
||||
const _selectedSubtitleLabel = useMemo(() => {
|
||||
if (subtitleIndex === -1) return t("item_card.subtitles.none");
|
||||
const track = subtitleTracks.find((t) => t.Index === subtitleIndex);
|
||||
return track?.DisplayTitle || track?.Language || t("item_card.subtitles");
|
||||
}, [subtitleTracks, subtitleIndex, t]);
|
||||
|
||||
// Handlers for option changes
|
||||
const handleAudioChange = useCallback(
|
||||
(index: number) => {
|
||||
onAudioIndexChange?.(index);
|
||||
},
|
||||
[onAudioIndexChange],
|
||||
);
|
||||
|
||||
const handleSubtitleChange = useCallback(
|
||||
(index: number) => {
|
||||
onSubtitleIndexChange?.(index);
|
||||
},
|
||||
[onSubtitleIndexChange],
|
||||
);
|
||||
|
||||
const {
|
||||
trickPlayUrl,
|
||||
@@ -139,7 +658,6 @@ export const Controls: FC<Props> = ({
|
||||
isRemoteScrubbing,
|
||||
showRemoteBubble,
|
||||
isSliding: isRemoteSliding,
|
||||
time: remoteTime,
|
||||
} = useRemoteControl({
|
||||
progress,
|
||||
min,
|
||||
@@ -153,6 +671,8 @@ export const Controls: FC<Props> = ({
|
||||
calculateTrickplayUrl,
|
||||
handleSeekForward,
|
||||
handleSeekBackward,
|
||||
disableSeeking: isModalOpen,
|
||||
onSwipeUp: handleSwipeUp,
|
||||
});
|
||||
|
||||
// Slider hook
|
||||
@@ -217,6 +737,12 @@ export const Controls: FC<Props> = ({
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
// Check if we have any settings to show
|
||||
const hasSettings =
|
||||
audioTracks.length > 0 ||
|
||||
subtitleTracks.length > 0 ||
|
||||
subtitleIndex !== undefined;
|
||||
|
||||
return (
|
||||
<View style={styles.controlsContainer} pointerEvents='box-none'>
|
||||
{/* Center Play Button - shown when paused */}
|
||||
@@ -228,9 +754,39 @@ export const Controls: FC<Props> = ({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Top hint - swipe up for settings */}
|
||||
{showControls && hasSettings && !isModalOpen && (
|
||||
<Animated.View
|
||||
style={[styles.topContainer, bottomAnimatedStyle]}
|
||||
pointerEvents='none'
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.topInner,
|
||||
{
|
||||
paddingRight: Math.max(insets.right, 48),
|
||||
paddingLeft: Math.max(insets.left, 48),
|
||||
paddingTop: Math.max(insets.top, 48),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.settingsHint}>
|
||||
<Ionicons
|
||||
name='chevron-up'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.5)'
|
||||
/>
|
||||
<Text style={styles.settingsHintText}>
|
||||
{t("player.swipe_up_settings")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<Animated.View
|
||||
style={[styles.bottomContainer, bottomAnimatedStyle]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
pointerEvents={showControls && !isModalOpen ? "auto" : "none"}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
@@ -294,7 +850,7 @@ export const Controls: FC<Props> = ({
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time Display - TV sized */}
|
||||
{/* Time Display */}
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.timeText}>
|
||||
{formatTimeString(currentTime, "ms")}
|
||||
@@ -305,6 +861,34 @@ export const Controls: FC<Props> = ({
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Settings panel - shows audio and subtitle options */}
|
||||
<TVSettingsPanel
|
||||
visible={openModal === "settings"}
|
||||
audioOptions={audioOptions}
|
||||
subtitleOptions={subtitleOptions}
|
||||
onAudioSelect={handleAudioChange}
|
||||
onSubtitleSelect={handleSubtitleChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Direct option selector modals (for future use) */}
|
||||
<TVOptionSelector
|
||||
visible={openModal === "audio"}
|
||||
title={t("item_card.audio")}
|
||||
options={audioOptions}
|
||||
onSelect={handleAudioChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
<TVOptionSelector
|
||||
visible={openModal === "subtitle"}
|
||||
title={t("item_card.subtitles")}
|
||||
options={subtitleOptions}
|
||||
onSelect={handleSubtitleChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -335,6 +919,17 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
paddingLeft: 8,
|
||||
},
|
||||
topContainer: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
topInner: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
bottomContainer: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
@@ -368,10 +963,28 @@ const styles = StyleSheet.create({
|
||||
timeContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: 12,
|
||||
},
|
||||
timeText: {
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
fontSize: 22,
|
||||
},
|
||||
settingsRow: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
},
|
||||
settingsHint: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
},
|
||||
settingsHintText: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,6 +31,10 @@ interface UseRemoteControlProps {
|
||||
calculateTrickplayUrl: (progressInTicks: number) => void;
|
||||
handleSeekForward: (seconds: number) => void;
|
||||
handleSeekBackward: (seconds: number) => void;
|
||||
/** When true, disables left/right seeking (e.g., when settings modal is open) */
|
||||
disableSeeking?: boolean;
|
||||
/** Callback when swipe up is detected - used to open settings */
|
||||
onSwipeUp?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +54,8 @@ export function useRemoteControl({
|
||||
calculateTrickplayUrl,
|
||||
handleSeekForward,
|
||||
handleSeekBackward,
|
||||
disableSeeking = false,
|
||||
onSwipeUp,
|
||||
}: UseRemoteControlProps) {
|
||||
const remoteScrubProgress = useSharedValue<number | null>(null);
|
||||
const isRemoteScrubbing = useSharedValue(false);
|
||||
@@ -63,6 +69,15 @@ export function useRemoteControl({
|
||||
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Use ref to track disableSeeking so the callback always has current value
|
||||
const disableSeekingRef = useRef(disableSeeking);
|
||||
disableSeekingRef.current = disableSeeking;
|
||||
|
||||
// Use ref for onSwipeUp callback
|
||||
const onSwipeUpRef = useRef(onSwipeUp);
|
||||
onSwipeUpRef.current = onSwipeUp;
|
||||
|
||||
// MPV uses ms
|
||||
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
|
||||
|
||||
@@ -82,15 +97,21 @@ export function useRemoteControl({
|
||||
|
||||
switch (evt.eventType) {
|
||||
case "longLeft": {
|
||||
if (disableSeekingRef.current) break;
|
||||
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
|
||||
break;
|
||||
}
|
||||
case "longRight": {
|
||||
if (disableSeekingRef.current) break;
|
||||
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
|
||||
break;
|
||||
}
|
||||
case "left":
|
||||
case "right": {
|
||||
// Skip seeking if disabled (e.g., when settings modal is open)
|
||||
if (disableSeekingRef.current) {
|
||||
break;
|
||||
}
|
||||
isRemoteScrubbing.value = true;
|
||||
setShowRemoteBubble(true);
|
||||
|
||||
@@ -127,12 +148,18 @@ export function useRemoteControl({
|
||||
break;
|
||||
}
|
||||
case "down":
|
||||
case "up":
|
||||
// cancel scrubbing on other directions
|
||||
// cancel scrubbing on down
|
||||
isRemoteScrubbing.value = false;
|
||||
remoteScrubProgress.value = null;
|
||||
setShowRemoteBubble(false);
|
||||
break;
|
||||
case "up":
|
||||
// cancel scrubbing and trigger swipe up callback (for settings)
|
||||
isRemoteScrubbing.value = false;
|
||||
remoteScrubProgress.value = null;
|
||||
setShowRemoteBubble(false);
|
||||
onSwipeUpRef.current?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user