mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-04 08:01:54 +01:00
1606 lines
43 KiB
TypeScript
1606 lines
43 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import type { Api } from "@jellyfin/sdk";
|
|
import type {
|
|
BaseItemDto,
|
|
MediaSourceInfo,
|
|
} from "@jellyfin/sdk/lib/generated-client";
|
|
import { BlurView } from "expo-blur";
|
|
import { useLocalSearchParams } from "expo-router";
|
|
import { useAtomValue } from "jotai";
|
|
import React, {
|
|
type FC,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
BackHandler,
|
|
Image,
|
|
Platform,
|
|
Pressable,
|
|
Animated as RNAnimated,
|
|
Easing as RNEasing,
|
|
ScrollView,
|
|
StyleSheet,
|
|
TVFocusGuideView,
|
|
View,
|
|
} from "react-native";
|
|
import Animated, {
|
|
cancelAnimation,
|
|
Easing,
|
|
runOnJS,
|
|
type SharedValue,
|
|
useAnimatedReaction,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withTiming,
|
|
} from "react-native-reanimated";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { Text } from "@/components/common/Text";
|
|
import { TVSubtitleSheet } from "@/components/common/TVSubtitleSheet";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
import { useSettings } from "@/utils/atoms/settings";
|
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
|
import { CONTROLS_CONSTANTS } from "./constants";
|
|
import { useRemoteControl } from "./hooks/useRemoteControl";
|
|
import { useVideoTime } from "./hooks/useVideoTime";
|
|
import { TrickplayBubble } from "./TrickplayBubble";
|
|
import { useControlsTimeout } from "./useControlsTimeout";
|
|
|
|
interface Props {
|
|
item: BaseItemDto;
|
|
isPlaying: boolean;
|
|
isSeeking: SharedValue<boolean>;
|
|
cacheProgress: SharedValue<number>;
|
|
progress: SharedValue<number>;
|
|
isBuffering?: boolean;
|
|
showControls: boolean;
|
|
togglePlay: () => void;
|
|
setShowControls: (shown: boolean) => void;
|
|
mediaSource?: MediaSourceInfo | null;
|
|
seek: (ticks: number) => void;
|
|
play: () => void;
|
|
pause: () => void;
|
|
audioIndex?: number;
|
|
subtitleIndex?: number;
|
|
onAudioIndexChange?: (index: number) => void;
|
|
onSubtitleIndexChange?: (index: number) => void;
|
|
previousItem?: BaseItemDto | null;
|
|
nextItem?: BaseItemDto | null;
|
|
goToPreviousItem?: () => void;
|
|
goToNextItem?: () => void;
|
|
/** Called when a subtitle is downloaded to the server (re-fetch media source needed) */
|
|
onServerSubtitleDownloaded?: () => void;
|
|
/** Add a local subtitle file to the player */
|
|
addSubtitleFile?: (path: string) => 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 [isReady, setIsReady] = useState(false);
|
|
const firstCardRef = useRef<View>(null);
|
|
|
|
// Animation values
|
|
const overlayOpacity = useRef(new RNAnimated.Value(0)).current;
|
|
const sheetTranslateY = useRef(new RNAnimated.Value(200)).current;
|
|
|
|
const initialSelectedIndex = useMemo(() => {
|
|
const idx = options.findIndex((o) => o.selected);
|
|
return idx >= 0 ? idx : 0;
|
|
}, [options]);
|
|
|
|
// Animate in when visible
|
|
useEffect(() => {
|
|
if (visible) {
|
|
// Reset values and animate in
|
|
overlayOpacity.setValue(0);
|
|
sheetTranslateY.setValue(200);
|
|
|
|
RNAnimated.parallel([
|
|
RNAnimated.timing(overlayOpacity, {
|
|
toValue: 1,
|
|
duration: 250,
|
|
easing: RNEasing.out(RNEasing.quad),
|
|
useNativeDriver: true,
|
|
}),
|
|
RNAnimated.timing(sheetTranslateY, {
|
|
toValue: 0,
|
|
duration: 300,
|
|
easing: RNEasing.out(RNEasing.cubic),
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
}
|
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
|
|
|
// Delay rendering to work around hasTVPreferredFocus timing issue
|
|
useEffect(() => {
|
|
if (visible) {
|
|
const timer = setTimeout(() => setIsReady(true), 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
setIsReady(false);
|
|
}, [visible]);
|
|
|
|
// Programmatic focus fallback
|
|
useEffect(() => {
|
|
if (isReady && firstCardRef.current) {
|
|
const timer = setTimeout(() => {
|
|
(firstCardRef.current as any)?.requestTVFocus?.();
|
|
}, 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isReady]);
|
|
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<RNAnimated.View
|
|
style={[selectorStyles.overlay, { opacity: overlayOpacity }]}
|
|
>
|
|
<RNAnimated.View
|
|
style={[
|
|
selectorStyles.sheetContainer,
|
|
{ transform: [{ translateY: sheetTranslateY }] },
|
|
]}
|
|
>
|
|
<BlurView
|
|
intensity={80}
|
|
tint='dark'
|
|
style={selectorStyles.blurContainer}
|
|
>
|
|
<TVFocusGuideView
|
|
autoFocus
|
|
trapFocusUp
|
|
trapFocusDown
|
|
trapFocusLeft
|
|
trapFocusRight
|
|
style={selectorStyles.content}
|
|
>
|
|
<Text style={selectorStyles.title}>{title}</Text>
|
|
{isReady && (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={selectorStyles.scrollView}
|
|
contentContainerStyle={selectorStyles.scrollContent}
|
|
>
|
|
{options.map((option, index) => (
|
|
<TVOptionCard
|
|
key={index}
|
|
ref={
|
|
index === initialSelectedIndex ? firstCardRef : undefined
|
|
}
|
|
label={option.label}
|
|
selected={option.selected}
|
|
hasTVPreferredFocus={index === initialSelectedIndex}
|
|
onPress={() => {
|
|
onSelect(option.value);
|
|
onClose();
|
|
}}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
)}
|
|
</TVFocusGuideView>
|
|
</BlurView>
|
|
</RNAnimated.View>
|
|
</RNAnimated.View>
|
|
);
|
|
};
|
|
|
|
// Option card for horizontal selector (with forwardRef for programmatic focus)
|
|
const TVOptionCard = React.forwardRef<
|
|
View,
|
|
{
|
|
label: string;
|
|
selected: boolean;
|
|
hasTVPreferredFocus?: boolean;
|
|
onPress: () => void;
|
|
}
|
|
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
|
|
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
|
|
ref={ref}
|
|
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 - switch automatically on focus */}
|
|
<View style={selectorStyles.tabRow}>
|
|
{audioOptions.length > 0 && (
|
|
<TVSettingsTab
|
|
label={t("item_card.audio")}
|
|
active={activeTab === "audio"}
|
|
onSelect={() => setActiveTab("audio")}
|
|
/>
|
|
)}
|
|
{subtitleOptions.length > 0 && (
|
|
<TVSettingsTab
|
|
label={t("item_card.subtitles.label")}
|
|
active={activeTab === "subtitle"}
|
|
onSelect={() => 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 - switches on focus, no click needed
|
|
const TVSettingsTab: FC<{
|
|
label: string;
|
|
active: boolean;
|
|
onSelect: () => void;
|
|
hasTVPreferredFocus?: boolean;
|
|
}> = ({ label, active, onSelect, 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
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
animateTo(1.05);
|
|
// Switch tab automatically on focus
|
|
onSelect();
|
|
}}
|
|
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>
|
|
);
|
|
};
|
|
|
|
// TV Control Button for player controls (icon only, no label)
|
|
const TVControlButton: FC<{
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
onPress: () => void;
|
|
onLongPress?: () => void;
|
|
onPressOut?: () => void;
|
|
disabled?: boolean;
|
|
hasTVPreferredFocus?: boolean;
|
|
size?: number;
|
|
delayLongPress?: number;
|
|
}> = ({
|
|
icon,
|
|
onPress,
|
|
onLongPress,
|
|
onPressOut,
|
|
disabled,
|
|
hasTVPreferredFocus,
|
|
size = 32,
|
|
delayLongPress = 300,
|
|
}) => {
|
|
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}
|
|
onLongPress={onLongPress}
|
|
onPressOut={onPressOut}
|
|
delayLongPress={delayLongPress}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
animateTo(1.15);
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
animateTo(1);
|
|
}}
|
|
disabled={disabled}
|
|
focusable={!disabled}
|
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
|
>
|
|
<RNAnimated.View
|
|
style={[
|
|
controlButtonStyles.button,
|
|
{
|
|
transform: [{ scale }],
|
|
backgroundColor: focused
|
|
? "rgba(255,255,255,0.3)"
|
|
: "rgba(255,255,255,0.1)",
|
|
borderColor: focused
|
|
? "rgba(255,255,255,0.8)"
|
|
: "rgba(255,255,255,0.2)",
|
|
opacity: disabled ? 0.3 : 1,
|
|
},
|
|
]}
|
|
>
|
|
<Ionicons name={icon} size={size} color='#fff' />
|
|
</RNAnimated.View>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
const controlButtonStyles = StyleSheet.create({
|
|
button: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 32,
|
|
borderWidth: 2,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
});
|
|
|
|
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,
|
|
},
|
|
sheetContainer: {
|
|
// Container for the sheet to enable slide animation
|
|
},
|
|
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,
|
|
},
|
|
});
|
|
|
|
// TV Next Episode Countdown component - horizontal layout with animated progress bar
|
|
const TVNextEpisodeCountdown: FC<{
|
|
nextItem: BaseItemDto;
|
|
api: Api | null;
|
|
show: boolean;
|
|
isPlaying: boolean;
|
|
onFinish: () => void;
|
|
}> = ({ nextItem, api, show, isPlaying, onFinish }) => {
|
|
const { t } = useTranslation();
|
|
const progress = useSharedValue(0);
|
|
const onFinishRef = useRef(onFinish);
|
|
|
|
// Keep onFinish ref updated
|
|
onFinishRef.current = onFinish;
|
|
|
|
// Get episode thumbnail
|
|
const imageUrl = getPrimaryImageUrl({
|
|
api,
|
|
item: nextItem,
|
|
width: 360, // 2x for retina
|
|
quality: 80,
|
|
});
|
|
|
|
// Handle animation based on show and isPlaying state
|
|
useEffect(() => {
|
|
if (show && isPlaying) {
|
|
// Start/restart animation from beginning
|
|
progress.value = 0;
|
|
progress.value = withTiming(
|
|
1,
|
|
{
|
|
duration: 8000, // 8 seconds (ends 2 seconds before episode end)
|
|
easing: Easing.linear,
|
|
},
|
|
(finished) => {
|
|
if (finished && onFinishRef.current) {
|
|
runOnJS(onFinishRef.current)();
|
|
}
|
|
},
|
|
);
|
|
} else {
|
|
// Pause: cancel animation and reset progress
|
|
cancelAnimation(progress);
|
|
progress.value = 0;
|
|
}
|
|
}, [show, isPlaying, progress]);
|
|
|
|
// Animated style for progress bar
|
|
const progressStyle = useAnimatedStyle(() => ({
|
|
width: `${progress.value * 100}%`,
|
|
}));
|
|
|
|
if (!show) return null;
|
|
|
|
return (
|
|
<View style={countdownStyles.container} pointerEvents='none'>
|
|
<BlurView intensity={80} tint='dark' style={countdownStyles.blur}>
|
|
<View style={countdownStyles.innerContainer}>
|
|
{/* Episode Thumbnail - left side */}
|
|
{imageUrl && (
|
|
<Image
|
|
source={{ uri: imageUrl }}
|
|
style={countdownStyles.thumbnail}
|
|
resizeMode='cover'
|
|
/>
|
|
)}
|
|
|
|
{/* Content - right side */}
|
|
<View style={countdownStyles.content}>
|
|
{/* Label: "Next Episode" */}
|
|
<Text style={countdownStyles.label}>
|
|
{t("player.next_episode")}
|
|
</Text>
|
|
|
|
{/* Series Name */}
|
|
<Text style={countdownStyles.seriesName} numberOfLines={1}>
|
|
{nextItem.SeriesName}
|
|
</Text>
|
|
|
|
{/* Episode Info: S#E# - Episode Name */}
|
|
<Text style={countdownStyles.episodeInfo} numberOfLines={1}>
|
|
S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
|
|
{nextItem.Name}
|
|
</Text>
|
|
|
|
{/* Progress Bar */}
|
|
<View style={countdownStyles.progressContainer}>
|
|
<Animated.View
|
|
style={[countdownStyles.progressBar, progressStyle]}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</BlurView>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const countdownStyles = StyleSheet.create({
|
|
container: {
|
|
position: "absolute",
|
|
bottom: 180,
|
|
right: 80,
|
|
zIndex: 100,
|
|
},
|
|
blur: {
|
|
borderRadius: 16,
|
|
overflow: "hidden",
|
|
},
|
|
innerContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
},
|
|
thumbnail: {
|
|
width: 180,
|
|
backgroundColor: "rgba(0,0,0,0.3)",
|
|
},
|
|
content: {
|
|
padding: 16,
|
|
justifyContent: "center",
|
|
width: 280,
|
|
},
|
|
label: {
|
|
fontSize: 13,
|
|
color: "rgba(255,255,255,0.5)",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
marginBottom: 4,
|
|
},
|
|
seriesName: {
|
|
fontSize: 16,
|
|
color: "rgba(255,255,255,0.7)",
|
|
marginBottom: 2,
|
|
},
|
|
episodeInfo: {
|
|
fontSize: 20,
|
|
color: "#fff",
|
|
fontWeight: "600",
|
|
marginBottom: 12,
|
|
},
|
|
progressContainer: {
|
|
height: 4,
|
|
backgroundColor: "rgba(255,255,255,0.2)",
|
|
borderRadius: 2,
|
|
overflow: "hidden",
|
|
},
|
|
progressBar: {
|
|
height: "100%",
|
|
backgroundColor: "#fff",
|
|
borderRadius: 2,
|
|
},
|
|
});
|
|
|
|
export const Controls: FC<Props> = ({
|
|
item,
|
|
seek,
|
|
play: _play,
|
|
pause: _pause,
|
|
togglePlay,
|
|
isPlaying,
|
|
isSeeking,
|
|
progress,
|
|
cacheProgress,
|
|
showControls,
|
|
setShowControls,
|
|
mediaSource,
|
|
audioIndex,
|
|
subtitleIndex,
|
|
onAudioIndexChange,
|
|
onSubtitleIndexChange,
|
|
previousItem,
|
|
nextItem: nextItemProp,
|
|
goToPreviousItem,
|
|
goToNextItem: goToNextItemProp,
|
|
onServerSubtitleDownloaded,
|
|
addSubtitleFile,
|
|
}) => {
|
|
const insets = useSafeAreaInsets();
|
|
const { t } = useTranslation();
|
|
const api = useAtomValue(apiAtom);
|
|
const { settings } = useSettings();
|
|
const router = useRouter();
|
|
const {
|
|
bitrateValue,
|
|
subtitleIndex: paramSubtitleIndex,
|
|
audioIndex: paramAudioIndex,
|
|
} = useLocalSearchParams<{
|
|
bitrateValue: string;
|
|
subtitleIndex: string;
|
|
audioIndex: string;
|
|
}>();
|
|
|
|
// TV is always online
|
|
const { nextItem: internalNextItem } = usePlaybackManager({
|
|
item,
|
|
isOffline: false,
|
|
});
|
|
|
|
// Use props if provided, otherwise use internal state
|
|
const nextItem = nextItemProp ?? internalNextItem;
|
|
|
|
// Modal state for option selectors
|
|
type ModalType = "audio" | "subtitle" | null;
|
|
const [openModal, setOpenModal] = useState<ModalType>(null);
|
|
const isModalOpen = openModal !== null;
|
|
|
|
// Track which button last opened a modal (for returning focus)
|
|
const [lastOpenedModal, setLastOpenedModal] = useState<ModalType>(null);
|
|
|
|
// Android TV BackHandler for closing modals
|
|
useEffect(() => {
|
|
if (Platform.OS === "android" && isModalOpen) {
|
|
const backHandler = BackHandler.addEventListener(
|
|
"hardwareBackPress",
|
|
() => {
|
|
setOpenModal(null);
|
|
return true;
|
|
},
|
|
);
|
|
return () => backHandler.remove();
|
|
}
|
|
}, [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]);
|
|
|
|
// 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]);
|
|
|
|
// Handlers for option changes
|
|
const handleAudioChange = useCallback(
|
|
(index: number) => {
|
|
onAudioIndexChange?.(index);
|
|
},
|
|
[onAudioIndexChange],
|
|
);
|
|
|
|
const handleSubtitleChange = useCallback(
|
|
(index: number) => {
|
|
onSubtitleIndexChange?.(index);
|
|
},
|
|
[onSubtitleIndexChange],
|
|
);
|
|
|
|
const {
|
|
trickPlayUrl,
|
|
calculateTrickplayUrl,
|
|
trickplayInfo,
|
|
prefetchAllTrickplayImages,
|
|
} = useTrickplay(item);
|
|
|
|
const min = useSharedValue(0);
|
|
const maxMs = ticksToMs(item.RunTimeTicks || 0);
|
|
const max = useSharedValue(maxMs);
|
|
|
|
// Animation values for controls
|
|
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
|
|
const bottomTranslateY = useSharedValue(showControls ? 0 : 50);
|
|
|
|
useEffect(() => {
|
|
prefetchAllTrickplayImages();
|
|
}, [prefetchAllTrickplayImages]);
|
|
|
|
// Animate controls visibility
|
|
useEffect(() => {
|
|
const animationConfig = {
|
|
duration: 300,
|
|
easing: Easing.out(Easing.quad),
|
|
};
|
|
|
|
controlsOpacity.value = withTiming(showControls ? 1 : 0, animationConfig);
|
|
bottomTranslateY.value = withTiming(showControls ? 0 : 30, animationConfig);
|
|
}, [showControls, controlsOpacity, bottomTranslateY]);
|
|
|
|
// Create animated style for bottom controls
|
|
const bottomAnimatedStyle = useAnimatedStyle(() => ({
|
|
opacity: controlsOpacity.value,
|
|
transform: [{ translateY: bottomTranslateY.value }],
|
|
}));
|
|
|
|
// Initialize progress values
|
|
useEffect(() => {
|
|
if (item) {
|
|
progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
|
|
max.value = ticksToMs(item.RunTimeTicks || 0);
|
|
}
|
|
}, [item, progress, max]);
|
|
|
|
// Time management hook
|
|
const { currentTime, remainingTime } = useVideoTime({
|
|
progress,
|
|
max,
|
|
isSeeking,
|
|
});
|
|
|
|
const getFinishTime = () => {
|
|
const now = new Date();
|
|
const finishTime = new Date(now.getTime() + remainingTime);
|
|
return finishTime.toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
});
|
|
};
|
|
|
|
const toggleControls = useCallback(() => {
|
|
setShowControls(!showControls);
|
|
}, [showControls, setShowControls]);
|
|
|
|
// Trickplay bubble state for seek buttons
|
|
const [showSeekBubble, setShowSeekBubble] = useState(false);
|
|
const [seekBubbleTime, setSeekBubbleTime] = useState({
|
|
hours: 0,
|
|
minutes: 0,
|
|
seconds: 0,
|
|
});
|
|
const seekBubbleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
null,
|
|
);
|
|
const continuousSeekRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const seekAccelerationRef = useRef(1);
|
|
const controlsInteractionRef = useRef<() => void>(() => {});
|
|
const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>(
|
|
() => {},
|
|
);
|
|
|
|
// Update trickplay time from ms
|
|
const updateSeekBubbleTime = useCallback((ms: number) => {
|
|
const totalSeconds = Math.floor(ms / 1000);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
setSeekBubbleTime({ hours, minutes, seconds });
|
|
}, []);
|
|
|
|
// Handler for back button to close modals
|
|
const handleBack = useCallback(() => {
|
|
if (isModalOpen) {
|
|
setOpenModal(null);
|
|
}
|
|
}, [isModalOpen]);
|
|
|
|
// Remote control hook for TV navigation (simplified - D-pad navigates buttons now)
|
|
const { isSliding: isRemoteSliding } = useRemoteControl({
|
|
showControls,
|
|
toggleControls,
|
|
togglePlay,
|
|
onBack: handleBack,
|
|
});
|
|
|
|
// Handlers for opening audio/subtitle sheets
|
|
const handleOpenAudioSheet = useCallback(() => {
|
|
setLastOpenedModal("audio");
|
|
setOpenModal("audio");
|
|
controlsInteractionRef.current();
|
|
}, []);
|
|
|
|
const handleOpenSubtitleSheet = useCallback(() => {
|
|
setLastOpenedModal("subtitle");
|
|
setOpenModal("subtitle");
|
|
controlsInteractionRef.current();
|
|
}, []);
|
|
|
|
// Progress value for the progress bar (directly from playback progress)
|
|
const effectiveProgress = useSharedValue(0);
|
|
|
|
// Threshold for detecting a seek (5 seconds) vs normal playback
|
|
const SEEK_THRESHOLD_MS = 5000;
|
|
|
|
// Update effective progress from playback progress
|
|
useAnimatedReaction(
|
|
() => progress.value,
|
|
(current, _previous) => {
|
|
const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS;
|
|
const progressDiff = Math.abs(current - effectiveProgress.value);
|
|
if (progressDiff >= progressUnit) {
|
|
// Animate large jumps (seeks), instant update for normal playback
|
|
if (progressDiff >= SEEK_THRESHOLD_MS) {
|
|
effectiveProgress.value = withTiming(current, {
|
|
duration: 200,
|
|
easing: Easing.out(Easing.quad),
|
|
});
|
|
} else {
|
|
effectiveProgress.value = current;
|
|
}
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const hideControls = useCallback(() => {
|
|
setShowControls(false);
|
|
}, [setShowControls]);
|
|
|
|
const { handleControlsInteraction } = useControlsTimeout({
|
|
showControls,
|
|
isSliding: isRemoteSliding,
|
|
episodeView: false,
|
|
onHideControls: hideControls,
|
|
timeout: TV_AUTO_HIDE_TIMEOUT,
|
|
disabled: false,
|
|
});
|
|
|
|
// Keep ref updated for seek button handlers
|
|
controlsInteractionRef.current = handleControlsInteraction;
|
|
|
|
// Seek button handlers (30 seconds)
|
|
const handleSeekForwardButton = useCallback(() => {
|
|
const newPosition = Math.min(max.value, progress.value + 30 * 1000);
|
|
progress.value = newPosition;
|
|
seek(newPosition);
|
|
|
|
// Show trickplay bubble
|
|
calculateTrickplayUrl(msToTicks(newPosition));
|
|
updateSeekBubbleTime(newPosition);
|
|
setShowSeekBubble(true);
|
|
|
|
// Hide bubble after delay
|
|
if (seekBubbleTimeoutRef.current) {
|
|
clearTimeout(seekBubbleTimeoutRef.current);
|
|
}
|
|
seekBubbleTimeoutRef.current = setTimeout(() => {
|
|
setShowSeekBubble(false);
|
|
}, 2000);
|
|
|
|
controlsInteractionRef.current();
|
|
}, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
|
|
|
|
const handleSeekBackwardButton = useCallback(() => {
|
|
const newPosition = Math.max(min.value, progress.value - 30 * 1000);
|
|
progress.value = newPosition;
|
|
seek(newPosition);
|
|
|
|
// Show trickplay bubble
|
|
calculateTrickplayUrl(msToTicks(newPosition));
|
|
updateSeekBubbleTime(newPosition);
|
|
setShowSeekBubble(true);
|
|
|
|
// Hide bubble after delay
|
|
if (seekBubbleTimeoutRef.current) {
|
|
clearTimeout(seekBubbleTimeoutRef.current);
|
|
}
|
|
seekBubbleTimeoutRef.current = setTimeout(() => {
|
|
setShowSeekBubble(false);
|
|
}, 2000);
|
|
|
|
controlsInteractionRef.current();
|
|
}, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
|
|
|
|
// Stop continuous seeking
|
|
const stopContinuousSeeking = useCallback(() => {
|
|
if (continuousSeekRef.current) {
|
|
clearInterval(continuousSeekRef.current);
|
|
continuousSeekRef.current = null;
|
|
}
|
|
seekAccelerationRef.current = 1;
|
|
|
|
// Hide trickplay bubble after delay
|
|
if (seekBubbleTimeoutRef.current) {
|
|
clearTimeout(seekBubbleTimeoutRef.current);
|
|
}
|
|
seekBubbleTimeoutRef.current = setTimeout(() => {
|
|
setShowSeekBubble(false);
|
|
}, 2000);
|
|
}, []);
|
|
|
|
// Start continuous seek forward (on long press)
|
|
const startContinuousSeekForward = useCallback(() => {
|
|
seekAccelerationRef.current = 1;
|
|
|
|
// Perform immediate first seek
|
|
handleSeekForwardButton();
|
|
|
|
// Start interval for continuous seeking with acceleration
|
|
continuousSeekRef.current = setInterval(() => {
|
|
const seekAmount =
|
|
CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK *
|
|
seekAccelerationRef.current *
|
|
1000;
|
|
const newPosition = Math.min(max.value, progress.value + seekAmount);
|
|
progress.value = newPosition;
|
|
seek(newPosition);
|
|
|
|
// Update trickplay bubble
|
|
calculateTrickplayUrl(msToTicks(newPosition));
|
|
updateSeekBubbleTime(newPosition);
|
|
|
|
// Accelerate for next interval
|
|
seekAccelerationRef.current *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION;
|
|
|
|
controlsInteractionRef.current();
|
|
}, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL);
|
|
}, [
|
|
handleSeekForwardButton,
|
|
max,
|
|
progress,
|
|
seek,
|
|
calculateTrickplayUrl,
|
|
updateSeekBubbleTime,
|
|
]);
|
|
|
|
// Start continuous seek backward (on long press)
|
|
const startContinuousSeekBackward = useCallback(() => {
|
|
seekAccelerationRef.current = 1;
|
|
|
|
// Perform immediate first seek
|
|
handleSeekBackwardButton();
|
|
|
|
// Start interval for continuous seeking with acceleration
|
|
continuousSeekRef.current = setInterval(() => {
|
|
const seekAmount =
|
|
CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK *
|
|
seekAccelerationRef.current *
|
|
1000;
|
|
const newPosition = Math.max(min.value, progress.value - seekAmount);
|
|
progress.value = newPosition;
|
|
seek(newPosition);
|
|
|
|
// Update trickplay bubble
|
|
calculateTrickplayUrl(msToTicks(newPosition));
|
|
updateSeekBubbleTime(newPosition);
|
|
|
|
// Accelerate for next interval
|
|
seekAccelerationRef.current *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION;
|
|
|
|
controlsInteractionRef.current();
|
|
}, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL);
|
|
}, [
|
|
handleSeekBackwardButton,
|
|
min,
|
|
progress,
|
|
seek,
|
|
calculateTrickplayUrl,
|
|
updateSeekBubbleTime,
|
|
]);
|
|
|
|
// Play/Pause button handler
|
|
const handlePlayPauseButton = useCallback(() => {
|
|
togglePlay();
|
|
controlsInteractionRef.current();
|
|
}, [togglePlay]);
|
|
|
|
// Previous item handler
|
|
const handlePreviousItem = useCallback(() => {
|
|
if (goToPreviousItem) {
|
|
goToPreviousItem();
|
|
}
|
|
controlsInteractionRef.current();
|
|
}, [goToPreviousItem]);
|
|
|
|
// Next item button handler
|
|
const handleNextItemButton = useCallback(() => {
|
|
if (goToNextItemProp) {
|
|
goToNextItemProp();
|
|
} else {
|
|
goToNextItemRef.current({ isAutoPlay: false });
|
|
}
|
|
controlsInteractionRef.current();
|
|
}, [goToNextItemProp]);
|
|
|
|
// goToNextItem function for auto-play
|
|
const goToNextItem = useCallback(
|
|
({ isAutoPlay: _isAutoPlay }: { isAutoPlay?: boolean } = {}) => {
|
|
if (!nextItem || !settings) {
|
|
return;
|
|
}
|
|
|
|
const previousIndexes = {
|
|
subtitleIndex: paramSubtitleIndex
|
|
? Number.parseInt(paramSubtitleIndex, 10)
|
|
: undefined,
|
|
audioIndex: paramAudioIndex
|
|
? Number.parseInt(paramAudioIndex, 10)
|
|
: undefined,
|
|
};
|
|
|
|
const {
|
|
mediaSource: newMediaSource,
|
|
audioIndex: defaultAudioIndex,
|
|
subtitleIndex: defaultSubtitleIndex,
|
|
} = getDefaultPlaySettings(nextItem, settings, {
|
|
indexes: previousIndexes,
|
|
source: mediaSource ?? undefined,
|
|
});
|
|
|
|
const queryParams = new URLSearchParams({
|
|
itemId: nextItem.Id ?? "",
|
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
|
mediaSourceId: newMediaSource?.Id ?? "",
|
|
bitrateValue: bitrateValue?.toString() ?? "",
|
|
playbackPosition:
|
|
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
|
}).toString();
|
|
|
|
router.replace(`player/direct-player?${queryParams}` as any);
|
|
},
|
|
[
|
|
nextItem,
|
|
settings,
|
|
paramSubtitleIndex,
|
|
paramAudioIndex,
|
|
mediaSource,
|
|
bitrateValue,
|
|
router,
|
|
],
|
|
);
|
|
|
|
// Keep ref updated for button handlers
|
|
goToNextItemRef.current = goToNextItem;
|
|
|
|
// Should show countdown? (TV always auto-plays next episode, no episode count limit)
|
|
const shouldShowCountdown = useMemo(() => {
|
|
if (!nextItem) return false;
|
|
if (item?.Type !== "Episode") return false;
|
|
return remainingTime > 0 && remainingTime <= 10000;
|
|
}, [nextItem, item, remainingTime]);
|
|
|
|
// Handler for when countdown animation finishes
|
|
const handleAutoPlayFinish = useCallback(() => {
|
|
goToNextItem({ isAutoPlay: true });
|
|
}, [goToNextItem]);
|
|
|
|
return (
|
|
<View style={styles.controlsContainer} pointerEvents='box-none'>
|
|
{/* Dark tint overlay when controls are visible */}
|
|
<Animated.View
|
|
style={[styles.darkOverlay, bottomAnimatedStyle]}
|
|
pointerEvents='none'
|
|
/>
|
|
|
|
{/* Next Episode Countdown - always visible when countdown active */}
|
|
{nextItem && (
|
|
<TVNextEpisodeCountdown
|
|
nextItem={nextItem}
|
|
api={api}
|
|
show={shouldShowCountdown}
|
|
isPlaying={isPlaying}
|
|
onFinish={handleAutoPlayFinish}
|
|
/>
|
|
)}
|
|
|
|
<Animated.View
|
|
style={[styles.bottomContainer, bottomAnimatedStyle]}
|
|
pointerEvents={showControls && !isModalOpen ? "auto" : "none"}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.bottomInner,
|
|
{
|
|
paddingRight: Math.max(insets.right, 48),
|
|
paddingLeft: Math.max(insets.left, 48),
|
|
paddingBottom: Math.max(insets.bottom, 24),
|
|
},
|
|
]}
|
|
onTouchStart={handleControlsInteraction}
|
|
>
|
|
{/* Metadata */}
|
|
<View style={styles.metadataContainer}>
|
|
{item?.Type === "Episode" && (
|
|
<Text
|
|
style={styles.subtitleText}
|
|
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
|
|
)}
|
|
<Text style={styles.titleText}>{item?.Name}</Text>
|
|
{item?.Type === "Movie" && (
|
|
<Text style={styles.subtitleText}>{item?.ProductionYear}</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Control Buttons Row */}
|
|
<View style={styles.controlButtonsRow}>
|
|
<TVControlButton
|
|
icon='play-skip-back'
|
|
onPress={handlePreviousItem}
|
|
disabled={isModalOpen || !previousItem}
|
|
size={28}
|
|
/>
|
|
<TVControlButton
|
|
icon='play-back'
|
|
onPress={handleSeekBackwardButton}
|
|
onLongPress={startContinuousSeekBackward}
|
|
onPressOut={stopContinuousSeeking}
|
|
disabled={isModalOpen}
|
|
size={28}
|
|
/>
|
|
<TVControlButton
|
|
icon={isPlaying ? "pause" : "play"}
|
|
onPress={handlePlayPauseButton}
|
|
disabled={isModalOpen}
|
|
hasTVPreferredFocus={!isModalOpen && lastOpenedModal === null}
|
|
size={36}
|
|
/>
|
|
<TVControlButton
|
|
icon='play-forward'
|
|
onPress={handleSeekForwardButton}
|
|
onLongPress={startContinuousSeekForward}
|
|
onPressOut={stopContinuousSeeking}
|
|
disabled={isModalOpen}
|
|
size={28}
|
|
/>
|
|
<TVControlButton
|
|
icon='play-skip-forward'
|
|
onPress={handleNextItemButton}
|
|
disabled={isModalOpen || !nextItem}
|
|
size={28}
|
|
/>
|
|
|
|
{/* Spacer to separate settings buttons from transport controls */}
|
|
<View style={styles.controlButtonsSpacer} />
|
|
|
|
{/* Audio button - only show when audio tracks are available */}
|
|
{audioOptions.length > 0 && (
|
|
<TVControlButton
|
|
icon='volume-high'
|
|
onPress={handleOpenAudioSheet}
|
|
disabled={isModalOpen}
|
|
hasTVPreferredFocus={
|
|
!isModalOpen && lastOpenedModal === "audio"
|
|
}
|
|
size={24}
|
|
/>
|
|
)}
|
|
|
|
{/* Subtitle button - always show to allow search even if no tracks */}
|
|
<TVControlButton
|
|
icon='text'
|
|
onPress={handleOpenSubtitleSheet}
|
|
disabled={isModalOpen}
|
|
hasTVPreferredFocus={
|
|
!isModalOpen && lastOpenedModal === "subtitle"
|
|
}
|
|
size={24}
|
|
/>
|
|
</View>
|
|
|
|
{/* Trickplay Bubble - shown when seeking */}
|
|
{showSeekBubble && (
|
|
<View style={styles.trickplayBubbleContainer}>
|
|
<TrickplayBubble
|
|
trickPlayUrl={trickPlayUrl}
|
|
trickplayInfo={trickplayInfo}
|
|
time={seekBubbleTime}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* Non-interactive Progress Bar */}
|
|
<View style={styles.progressBarContainer} pointerEvents='none'>
|
|
<View style={styles.progressTrack}>
|
|
{/* Cache progress */}
|
|
<Animated.View
|
|
style={[
|
|
styles.cacheProgress,
|
|
useAnimatedStyle(() => ({
|
|
width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`,
|
|
})),
|
|
]}
|
|
/>
|
|
{/* Playback progress */}
|
|
<Animated.View
|
|
style={[
|
|
styles.progressFill,
|
|
useAnimatedStyle(() => ({
|
|
width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`,
|
|
})),
|
|
]}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Time Display */}
|
|
<View style={styles.timeContainer}>
|
|
<Text style={styles.timeText}>
|
|
{formatTimeString(currentTime, "ms")}
|
|
</Text>
|
|
<View style={styles.timeRight}>
|
|
<Text style={styles.timeText}>
|
|
-{formatTimeString(remainingTime, "ms")}
|
|
</Text>
|
|
<Text style={styles.endsAtText}>
|
|
{t("player.ends_at")} {getFinishTime()}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Animated.View>
|
|
|
|
{/* Audio option selector */}
|
|
<TVOptionSelector
|
|
visible={openModal === "audio"}
|
|
title={t("item_card.audio")}
|
|
options={audioOptions}
|
|
onSelect={handleAudioChange}
|
|
onClose={() => setOpenModal(null)}
|
|
/>
|
|
|
|
{/* Subtitle Sheet with tabs for tracks and search */}
|
|
<TVSubtitleSheet
|
|
visible={openModal === "subtitle"}
|
|
item={item}
|
|
mediaSourceId={mediaSource?.Id}
|
|
subtitleTracks={subtitleTracks}
|
|
currentSubtitleIndex={subtitleIndex ?? -1}
|
|
onSubtitleChange={handleSubtitleChange}
|
|
onClose={() => setOpenModal(null)}
|
|
onServerSubtitleDownloaded={onServerSubtitleDownloaded}
|
|
onLocalSubtitleDownloaded={addSubtitleFile}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
controlsContainer: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
},
|
|
darkOverlay: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
|
},
|
|
bottomContainer: {
|
|
position: "absolute",
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 10,
|
|
},
|
|
bottomInner: {
|
|
flexDirection: "column",
|
|
},
|
|
metadataContainer: {
|
|
marginBottom: 16,
|
|
},
|
|
subtitleText: {
|
|
color: "rgba(255,255,255,0.6)",
|
|
fontSize: 18,
|
|
},
|
|
titleText: {
|
|
color: "#fff",
|
|
fontSize: 28,
|
|
fontWeight: "bold",
|
|
},
|
|
controlButtonsRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 16,
|
|
marginBottom: 20,
|
|
paddingVertical: 8,
|
|
},
|
|
controlButtonsSpacer: {
|
|
flex: 1,
|
|
},
|
|
trickplayBubbleContainer: {
|
|
position: "absolute",
|
|
bottom: 120,
|
|
left: 0,
|
|
right: 0,
|
|
alignItems: "center",
|
|
zIndex: 20,
|
|
},
|
|
progressBarContainer: {
|
|
height: TV_SEEKBAR_HEIGHT,
|
|
justifyContent: "center",
|
|
marginBottom: 8,
|
|
},
|
|
progressTrack: {
|
|
height: TV_SEEKBAR_HEIGHT,
|
|
backgroundColor: "rgba(255,255,255,0.2)",
|
|
borderRadius: 8,
|
|
overflow: "hidden",
|
|
},
|
|
cacheProgress: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
height: "100%",
|
|
backgroundColor: "rgba(255,255,255,0.3)",
|
|
borderRadius: 8,
|
|
},
|
|
progressFill: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
height: "100%",
|
|
backgroundColor: "#fff",
|
|
borderRadius: 8,
|
|
},
|
|
timeContainer: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginTop: 12,
|
|
},
|
|
timeText: {
|
|
color: "rgba(255,255,255,0.7)",
|
|
fontSize: 22,
|
|
},
|
|
timeRight: {
|
|
flexDirection: "column",
|
|
alignItems: "flex-end",
|
|
},
|
|
endsAtText: {
|
|
color: "rgba(255,255,255,0.5)",
|
|
fontSize: 16,
|
|
marginTop: 2,
|
|
},
|
|
});
|