This commit is contained in:
Fredrik Burmester
2026-01-16 08:57:22 +01:00
parent 3e695def23
commit 4cdbab7d19
3 changed files with 363 additions and 50 deletions

View File

@@ -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 = () => {
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black'
>
<View
style={{
height: item?.Type === "Episode" ? 300 : 450,
}}
className='bg-transparent rounded-lg mb-4 w-full'
/>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
{Platform.isTV && ItemContentSkeletonTV ? (
<ItemContentSkeletonTV />
) : (
<View style={{ paddingHorizontal: 16, width: "100%" }}>
<View
style={{
height: item?.Type === "Episode" ? 300 : 450,
}}
className='bg-transparent rounded-lg mb-4 w-full'
/>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</View>
)}
</Animated.View>
{item && <ItemContent item={item} itemWithSources={itemWithSources} />}
</View>

View File

@@ -198,9 +198,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = 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<ItemContentTVProps> = React.memo(
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 40,
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + 80,
}}
@@ -449,35 +447,10 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
: t("common.play")}
</Text>
</TVFocusableButton>
{!isOffline && item.Type !== "Program" && (
<TVFocusableButton
onPress={() => {
// Info/More options action
}}
variant='secondary'
>
<Ionicons
name='information-circle-outline'
size={24}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: 18,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("item_card.more_info")}
</Text>
</TVFocusableButton>
)}
</View>
{/* Progress bar (if partially watched) */}
{hasProgress && item.RunTimeTicks && (
{hasProgress && item.RunTimeTicks != null && (
<View style={{ maxWidth: 400, marginBottom: 24 }}>
<View
style={{

View File

@@ -0,0 +1,330 @@
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useEffect } from "react";
import { StyleSheet, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import Animated, {
Easing,
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 { useTrickplay } from "@/hooks/useTrickplay";
import { formatTimeString, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoSlider } from "./hooks/useVideoSlider";
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;
}
const TV_SEEKBAR_HEIGHT = 16;
const TV_AUTO_HIDE_TIMEOUT = 5000;
export const Controls: FC<Props> = ({
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 (
<View style={styles.controlsContainer} pointerEvents='box-none'>
<Animated.View
style={[styles.bottomContainer, bottomAnimatedStyle]}
pointerEvents={showControls ? "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>
{/* Large Seekbar */}
<View
style={styles.sliderContainer}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
renderThumb={() => null}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={styles.sliderTrack}
renderBubble={() =>
(isSliding || showRemoteBubble) && (
<TrickplayBubble
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={time}
/>
)
}
sliderHeight={TV_SEEKBAR_HEIGHT}
thumbWidth={0}
progress={effectiveProgress}
minimumValue={min}
maximumValue={max}
/>
</View>
{/* Time Display - TV sized */}
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
{formatTimeString(currentTime, "ms")}
</Text>
<Text style={styles.timeText}>
-{formatTimeString(remainingTime, "ms")}
</Text>
</View>
</View>
</Animated.View>
</View>
);
};
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,
},
});