This commit is contained in:
Fredrik Burmester
2026-01-18 19:33:42 +01:00
parent f9a3a1f9f6
commit 83babc2687
22 changed files with 1642 additions and 1449 deletions

View File

@@ -1,10 +1,7 @@
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 {
@@ -16,17 +13,9 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Image,
Pressable,
Animated as RNAnimated,
StyleSheet,
View,
} from "react-native";
import { StyleSheet, View } from "react-native";
import Animated, {
cancelAnimation,
Easing,
runOnJS,
type SharedValue,
useAnimatedReaction,
useAnimatedStyle,
@@ -35,7 +24,7 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv";
import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv";
import useRouter from "@/hooks/useAppRouter";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
@@ -45,7 +34,6 @@ 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";
@@ -84,214 +72,6 @@ interface Props {
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 (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
onPressOut={onPressOut}
delayLongPress={delayLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
>
<RNAnimated.View
style={[
controlButtonStyles.button,
animatedStyle,
{
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",
},
});
// 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 (
<View style={countdownStyles.container} pointerEvents='none'>
<BlurView intensity={80} tint='dark' style={countdownStyles.blur}>
<View style={countdownStyles.innerContainer}>
{imageUrl && (
<Image
source={{ uri: imageUrl }}
style={countdownStyles.thumbnail}
resizeMode='cover'
/>
)}
<View style={countdownStyles.content}>
<Text style={countdownStyles.label}>
{t("player.next_episode")}
</Text>
<Text style={countdownStyles.seriesName} numberOfLines={1}>
{nextItem.SeriesName}
</Text>
<Text style={countdownStyles.episodeInfo} numberOfLines={1}>
S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
{nextItem.Name}
</Text>
<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,

View File

@@ -16,7 +16,6 @@ import {
ActivityIndicator,
Animated,
Easing,
Pressable,
ScrollView,
StyleSheet,
TVFocusGuideView,
@@ -25,8 +24,10 @@ import {
import { Text } from "@/components/common/Text";
import {
TVCancelButton,
TVLanguageCard,
TVSubtitleResultCard,
TVTabButton,
useTVFocusAnimation,
TVTrackCard,
} from "@/components/tv";
import {
type SubtitleSearchResult,
@@ -48,327 +49,6 @@ interface TVSubtitleSheetProps {
type TabType = "tracks" | "download";
// Track card for subtitle track selection
const TVTrackCard = React.forwardRef<
View,
{
label: string;
sublabel?: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}
>(({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.trackCard,
animatedStyle,
{
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.trackCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={2}
>
{label}
</Text>
{sublabel && (
<Text
style={[
styles.trackCardSublabel,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
numberOfLines={1}
>
{sublabel}
</Text>
)}
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
});
// Language selector card
const LanguageCard = React.forwardRef<
View,
{
code: string;
name: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}
>(({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.languageCard,
animatedStyle,
{
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.languageCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={1}
>
{name}
</Text>
<Text
style={[
styles.languageCardCode,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
>
{code.toUpperCase()}
</Text>
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
});
// Subtitle result card
const SubtitleResultCard = React.forwardRef<
View,
{
result: SubtitleSearchResult;
hasTVPreferredFocus?: boolean;
isDownloading?: boolean;
onPress: () => void;
}
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 });
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={isDownloading}
>
<Animated.View
style={[
styles.resultCard,
animatedStyle,
{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
borderColor: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.1)",
},
]}
>
{/* Provider/Source badge */}
<View
style={[
styles.providerBadge,
{
backgroundColor: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Text
style={[
styles.providerText,
{ color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)" },
]}
>
{result.providerName}
</Text>
</View>
{/* Name */}
<Text
style={[styles.resultName, { color: focused ? "#000" : "#fff" }]}
numberOfLines={2}
>
{result.name}
</Text>
{/* Meta info row */}
<View style={styles.resultMeta}>
{/* Format */}
<Text
style={[
styles.resultMetaText,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
>
{result.format?.toUpperCase()}
</Text>
{/* Rating if available */}
{result.communityRating !== undefined &&
result.communityRating > 0 && (
<View style={styles.ratingContainer}>
<Ionicons
name='star'
size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
style={[
styles.resultMetaText,
{
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
},
]}
>
{result.communityRating.toFixed(1)}
</Text>
</View>
)}
{/* Download count if available */}
{result.downloadCount !== undefined && result.downloadCount > 0 && (
<View style={styles.downloadCountContainer}>
<Ionicons
name='download-outline'
size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
style={[
styles.resultMetaText,
{
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
},
]}
>
{result.downloadCount.toLocaleString()}
</Text>
</View>
)}
</View>
{/* Flags */}
<View style={styles.flagsContainer}>
{result.isHashMatch && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,150,0,0.2)"
: "rgba(0,200,0,0.2)",
},
]}
>
<Text style={styles.flagText}>Hash Match</Text>
</View>
)}
{result.hearingImpaired && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Ionicons
name='ear-outline'
size={12}
color={focused ? "#000" : "#fff"}
/>
</View>
)}
{result.aiTranslated && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,0,150,0.2)"
: "rgba(100,100,255,0.2)",
},
]}
>
<Text style={styles.flagText}>AI</Text>
</View>
)}
</View>
{/* Loading indicator when downloading */}
{isDownloading && (
<View style={styles.downloadingOverlay}>
<ActivityIndicator size='small' color='#fff' />
</View>
)}
</Animated.View>
</Pressable>
);
});
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
visible,
item,
@@ -627,7 +307,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<LanguageCard
<TVLanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
@@ -708,7 +388,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<SubtitleResultCard
<TVSubtitleResultCard
key={result.id}
result={result}
hasTVPreferredFocus={index === 0}
@@ -810,27 +490,6 @@ const styles = StyleSheet.create({
paddingVertical: 8,
gap: 12,
},
trackCard: {
width: 180,
height: 80,
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
trackCardText: {
fontSize: 16,
textAlign: "center",
},
trackCardSublabel: {
fontSize: 12,
marginTop: 2,
},
checkmark: {
position: "absolute",
top: 8,
right: 8,
},
languageScroll: {
overflow: "visible",
},
@@ -839,22 +498,6 @@ const styles = StyleSheet.create({
paddingVertical: 8,
gap: 10,
},
languageCard: {
width: 120,
height: 60,
borderRadius: 12,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
languageCardText: {
fontSize: 15,
fontWeight: "500",
},
languageCardCode: {
fontSize: 11,
marginTop: 2,
},
resultsScroll: {
overflow: "visible",
},
@@ -863,73 +506,6 @@ const styles = StyleSheet.create({
paddingVertical: 8,
gap: 12,
},
resultCard: {
width: 220,
minHeight: 120,
borderRadius: 14,
padding: 14,
borderWidth: 1,
},
providerBadge: {
alignSelf: "flex-start",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
marginBottom: 8,
},
providerText: {
fontSize: 11,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
},
resultName: {
fontSize: 14,
fontWeight: "500",
marginBottom: 8,
lineHeight: 18,
},
resultMeta: {
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 8,
},
resultMetaText: {
fontSize: 12,
},
ratingContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
downloadCountContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
flagsContainer: {
flexDirection: "row",
gap: 6,
flexWrap: "wrap",
},
flag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
flagText: {
fontSize: 10,
fontWeight: "600",
color: "#fff",
},
downloadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
},
loadingContainer: {
paddingVertical: 40,
alignItems: "center",