From 4cdbab7d198335863a516a4cecd17ebad82cb939 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:57:22 +0100 Subject: [PATCH] wip --- .../items/page.tsx | 50 +-- components/ItemContent.tv.tsx | 33 +- .../video-player/controls/Controls.tv.tsx | 330 ++++++++++++++++++ 3 files changed, 363 insertions(+), 50 deletions(-) create mode 100644 components/video-player/controls/Controls.tv.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx index d6107217..6baf9ca6 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx @@ -3,7 +3,7 @@ import { useLocalSearchParams } from "expo-router"; import type React from "react"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import Animated, { runOnJS, useAnimatedStyle, @@ -15,6 +15,10 @@ import { ItemContent } from "@/components/ItemContent"; import { useItemQuery } from "@/hooks/useItemQuery"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; +const ItemContentSkeletonTV = Platform.isTV + ? require("@/components/ItemContentSkeleton.tv").ItemContentSkeletonTV + : null; + const Page: React.FC = () => { const { id } = useLocalSearchParams() as { id: string }; const { t } = useTranslation(); @@ -81,26 +85,32 @@ const Page: React.FC = () => { - - - - - - - - - - - - - + {Platform.isTV && ItemContentSkeletonTV ? ( + + ) : ( + + + + + + + + + + + + + + + + )} {item && } diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 8f56f26f..bd075b9c 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -198,9 +198,7 @@ export const ItemContentTV: React.FC = React.memo( const duration = item.RunTimeTicks ? runtimeTicksToMinutes(item.RunTimeTicks) : null; - const hasProgress = - item.UserData?.PlaybackPositionTicks && - item.UserData.PlaybackPositionTicks > 0; + const hasProgress = (item.UserData?.PlaybackPositionTicks ?? 0) > 0; const remainingTime = hasProgress ? runtimeTicksToMinutes( (item.RunTimeTicks || 0) - @@ -271,7 +269,7 @@ export const ItemContentTV: React.FC = React.memo( = React.memo( : t("common.play")} - - {!isOffline && item.Type !== "Program" && ( - { - // Info/More options action - }} - variant='secondary' - > - - - {t("item_card.more_info")} - - - )} {/* Progress bar (if partially watched) */} - {hasProgress && item.RunTimeTicks && ( + {hasProgress && item.RunTimeTicks != null && ( ; + 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; +} + +const TV_SEEKBAR_HEIGHT = 16; +const TV_AUTO_HIDE_TIMEOUT = 5000; + +export const Controls: FC = ({ + item, + seek, + play, + pause, + togglePlay, + isPlaying, + isSeeking, + progress, + cacheProgress, + showControls, + setShowControls, +}) => { + const insets = useSafeAreaInsets(); + + 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 toggleControls = useCallback(() => { + setShowControls(!showControls); + }, [showControls, setShowControls]); + + // Remote control hook for TV navigation + const { + remoteScrubProgress, + isRemoteScrubbing, + showRemoteBubble, + isSliding: isRemoteSliding, + time: remoteTime, + } = useRemoteControl({ + progress, + min, + max, + showControls, + isPlaying, + seek, + play, + togglePlay, + toggleControls, + calculateTrickplayUrl, + handleSeekForward: () => {}, + handleSeekBackward: () => {}, + }); + + // Slider hook + const { + isSliding, + time, + handleSliderStart, + handleTouchStart, + handleTouchEnd, + handleSliderComplete, + handleSliderChange, + } = useVideoSlider({ + progress, + isSeeking, + isPlaying, + seek, + play, + pause, + calculateTrickplayUrl, + showControls, + }); + + const effectiveProgress = useSharedValue(0); + + // Recompute progress for remote scrubbing + useAnimatedReaction( + () => ({ + isScrubbing: isRemoteScrubbing.value, + scrub: remoteScrubProgress.value, + actual: progress.value, + }), + (current, previous) => { + if ( + current.isScrubbing !== previous?.isScrubbing || + current.isScrubbing + ) { + effectiveProgress.value = + current.isScrubbing && current.scrub != null + ? current.scrub + : current.actual; + } else { + const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS; + const progressDiff = Math.abs(current.actual - effectiveProgress.value); + if (progressDiff >= progressUnit) { + effectiveProgress.value = current.actual; + } + } + }, + [], + ); + + const hideControls = useCallback(() => { + setShowControls(false); + }, [setShowControls]); + + const { handleControlsInteraction } = useControlsTimeout({ + showControls, + isSliding: isSliding || isRemoteSliding, + episodeView: false, + onHideControls: hideControls, + timeout: TV_AUTO_HIDE_TIMEOUT, + disabled: false, + }); + + return ( + + + + {/* Metadata */} + + {item?.Type === "Episode" && ( + + {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} + + )} + {item?.Name} + {item?.Type === "Movie" && ( + {item?.ProductionYear} + )} + + + {/* Large Seekbar */} + + null} + cache={cacheProgress} + onSlidingStart={handleSliderStart} + onSlidingComplete={handleSliderComplete} + onValueChange={handleSliderChange} + containerStyle={styles.sliderTrack} + renderBubble={() => + (isSliding || showRemoteBubble) && ( + + ) + } + sliderHeight={TV_SEEKBAR_HEIGHT} + thumbWidth={0} + progress={effectiveProgress} + minimumValue={min} + maximumValue={max} + /> + + + {/* Time Display - TV sized */} + + + {formatTimeString(currentTime, "ms")} + + + -{formatTimeString(remainingTime, "ms")} + + + + + + ); +}; + +const styles = StyleSheet.create({ + controlsContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + 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", + }, + sliderContainer: { + height: TV_SEEKBAR_HEIGHT, + justifyContent: "center", + alignItems: "stretch", + }, + sliderTrack: { + borderRadius: 100, + }, + timeContainer: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 12, + }, + timeText: { + color: "rgba(255,255,255,0.7)", + fontSize: 22, + }, +});