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,
+ },
+});