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 { type FC, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { Image, Pressable, Animated as RNAnimated, StyleSheet, 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 { useTVFocusAnimation } from "@/components/tv"; import useRouter from "@/hooks/useAppRouter"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; 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; cacheProgress: SharedValue; progress: SharedValue; 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; onServerSubtitleDownloaded?: () => void; addSubtitleFile?: (path: string) => void; } const TV_SEEKBAR_HEIGHT = 16; const TV_AUTO_HIDE_TIMEOUT = 5000; // 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, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 }); return ( ); }; const controlButtonStyles = StyleSheet.create({ button: { width: 64, height: 64, borderRadius: 32, borderWidth: 2, justifyContent: "center", alignItems: "center", }, }); // 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); onFinishRef.current = onFinish; const imageUrl = getPrimaryImageUrl({ api, item: nextItem, width: 360, quality: 80, }); useEffect(() => { if (show && isPlaying) { progress.value = 0; progress.value = withTiming( 1, { duration: 8000, easing: Easing.linear, }, (finished) => { if (finished && onFinishRef.current) { runOnJS(onFinishRef.current)(); } }, ); } else { cancelAnimation(progress); progress.value = 0; } }, [show, isPlaying, progress]); const progressStyle = useAnimatedStyle(() => ({ width: `${progress.value * 100}%`, })); if (!show) return null; return ( {imageUrl && ( )} {t("player.next_episode")} {nextItem.SeriesName} S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "} {nextItem.Name} ); }; 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 = ({ 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; }>(); const { nextItem: internalNextItem } = usePlaybackManager({ item, isOffline: false, }); const nextItem = nextItemProp ?? internalNextItem; // TV Option Modal hook for audio selector const { showOptions } = useTVOptionModal(); // TV Subtitle Modal hook const { showSubtitleModal } = useTVSubtitleModal(); // Track which button should have preferred focus when controls show type LastModalType = "audio" | "subtitle" | null; const [lastOpenedModal, setLastOpenedModal] = useState(null); const audioTracks = useMemo(() => { return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; }, [mediaSource]); const subtitleTracks = useMemo(() => { return ( mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? [] ); }, [mediaSource]); const audioOptions: TVOptionItem[] = useMemo(() => { return audioTracks.map((track) => ({ label: track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, value: track.Index!, selected: track.Index === audioIndex, })); }, [audioTracks, audioIndex]); 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); const controlsOpacity = useSharedValue(showControls ? 1 : 0); const bottomTranslateY = useSharedValue(showControls ? 0 : 50); useEffect(() => { prefetchAllTrickplayImages(); }, [prefetchAllTrickplayImages]); 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]); const bottomAnimatedStyle = useAnimatedStyle(() => ({ opacity: controlsOpacity.value, transform: [{ translateY: bottomTranslateY.value }], })); useEffect(() => { if (item) { progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks); max.value = ticksToMs(item.RunTimeTicks || 0); } }, [item, progress, max]); 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]); const [showSeekBubble, setShowSeekBubble] = useState(false); const [seekBubbleTime, setSeekBubbleTime] = useState({ hours: 0, minutes: 0, seconds: 0, }); const seekBubbleTimeoutRef = useRef | null>( null, ); const continuousSeekRef = useRef | null>(null); const seekAccelerationRef = useRef(1); const controlsInteractionRef = useRef<() => void>(() => {}); const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>( () => {}, ); 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 }); }, []); const handleBack = useCallback(() => { // No longer needed since modals are screen-based }, []); const { isSliding: isRemoteSliding } = useRemoteControl({ showControls, toggleControls, togglePlay, onBack: handleBack, }); const handleOpenAudioSheet = useCallback(() => { setLastOpenedModal("audio"); showOptions({ title: t("item_card.audio"), options: audioOptions, onSelect: handleAudioChange, }); controlsInteractionRef.current(); }, [showOptions, t, audioOptions, handleAudioChange]); const handleServerSubtitleDownloaded = useCallback(() => { onServerSubtitleDownloaded?.(); }, [onServerSubtitleDownloaded]); const handleLocalSubtitleDownloaded = useCallback( (path: string) => { addSubtitleFile?.(path); }, [addSubtitleFile], ); const handleOpenSubtitleSheet = useCallback(() => { setLastOpenedModal("subtitle"); showSubtitleModal({ item, mediaSourceId: mediaSource?.Id, subtitleTracks, currentSubtitleIndex: subtitleIndex ?? -1, onSubtitleIndexChange: handleSubtitleChange, onServerSubtitleDownloaded: handleServerSubtitleDownloaded, onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded, }); controlsInteractionRef.current(); }, [ showSubtitleModal, item, mediaSource?.Id, subtitleTracks, subtitleIndex, handleSubtitleChange, handleServerSubtitleDownloaded, handleLocalSubtitleDownloaded, ]); const effectiveProgress = useSharedValue(0); const SEEK_THRESHOLD_MS = 5000; useAnimatedReaction( () => progress.value, (current, _previous) => { const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS; const progressDiff = Math.abs(current - effectiveProgress.value); if (progressDiff >= progressUnit) { 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, }); controlsInteractionRef.current = handleControlsInteraction; const handleSeekForwardButton = useCallback(() => { const newPosition = Math.min(max.value, progress.value + 30 * 1000); progress.value = newPosition; seek(newPosition); calculateTrickplayUrl(msToTicks(newPosition)); updateSeekBubbleTime(newPosition); setShowSeekBubble(true); 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); calculateTrickplayUrl(msToTicks(newPosition)); updateSeekBubbleTime(newPosition); setShowSeekBubble(true); if (seekBubbleTimeoutRef.current) { clearTimeout(seekBubbleTimeoutRef.current); } seekBubbleTimeoutRef.current = setTimeout(() => { setShowSeekBubble(false); }, 2000); controlsInteractionRef.current(); }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); const stopContinuousSeeking = useCallback(() => { if (continuousSeekRef.current) { clearInterval(continuousSeekRef.current); continuousSeekRef.current = null; } seekAccelerationRef.current = 1; if (seekBubbleTimeoutRef.current) { clearTimeout(seekBubbleTimeoutRef.current); } seekBubbleTimeoutRef.current = setTimeout(() => { setShowSeekBubble(false); }, 2000); }, []); const startContinuousSeekForward = useCallback(() => { seekAccelerationRef.current = 1; handleSeekForwardButton(); 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); calculateTrickplayUrl(msToTicks(newPosition)); updateSeekBubbleTime(newPosition); seekAccelerationRef.current *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION; controlsInteractionRef.current(); }, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL); }, [ handleSeekForwardButton, max, progress, seek, calculateTrickplayUrl, updateSeekBubbleTime, ]); const startContinuousSeekBackward = useCallback(() => { seekAccelerationRef.current = 1; handleSeekBackwardButton(); 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); calculateTrickplayUrl(msToTicks(newPosition)); updateSeekBubbleTime(newPosition); seekAccelerationRef.current *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION; controlsInteractionRef.current(); }, CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL); }, [ handleSeekBackwardButton, min, progress, seek, calculateTrickplayUrl, updateSeekBubbleTime, ]); const handlePlayPauseButton = useCallback(() => { togglePlay(); controlsInteractionRef.current(); }, [togglePlay]); const handlePreviousItem = useCallback(() => { if (goToPreviousItem) { goToPreviousItem(); } controlsInteractionRef.current(); }, [goToPreviousItem]); const handleNextItemButton = useCallback(() => { if (goToNextItemProp) { goToNextItemProp(); } else { goToNextItemRef.current({ isAutoPlay: false }); } controlsInteractionRef.current(); }, [goToNextItemProp]); 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, ], ); goToNextItemRef.current = goToNextItem; const shouldShowCountdown = useMemo(() => { if (!nextItem) return false; if (item?.Type !== "Episode") return false; return remainingTime > 0 && remainingTime <= 10000; }, [nextItem, item, remainingTime]); const handleAutoPlayFinish = useCallback(() => { goToNextItem({ isAutoPlay: true }); }, [goToNextItem]); return ( {nextItem && ( )} {item?.Type === "Episode" && ( {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} )} {item?.Name} {item?.Type === "Movie" && ( {item?.ProductionYear} )} {audioOptions.length > 0 && ( )} {showSeekBubble && ( )} ({ width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, })), ]} /> ({ width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, })), ]} /> {formatTimeString(currentTime, "ms")} -{formatTimeString(remainingTime, "ms")} {t("player.ends_at")} {getFinishTime()} ); }; 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, }, });