mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-13 01:10:22 +01:00
wip: controls next up
This commit is contained in:
@@ -14,6 +14,7 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
|
import { TVSeriesPage } from "@/components/series/TVSeriesPage";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
@@ -159,6 +160,19 @@ const page: React.FC = () => {
|
|||||||
// For offline mode, we can show the page even without backdropUrl
|
// For offline mode, we can show the page even without backdropUrl
|
||||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||||
|
|
||||||
|
// TV version
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
|
<TVSeriesPage
|
||||||
|
item={item}
|
||||||
|
allEpisodes={allEpisodes}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</OfflineModeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OfflineModeProvider isOffline={isOffline}>
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ const TVOptionButton: React.FC<{
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
animateTo(1.04);
|
animateTo(1.02);
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
@@ -501,6 +501,10 @@ const TVOptionButton: React.FC<{
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 12 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -1001,12 +1005,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
</TVFocusableButton>
|
</TVFocusableButton>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Playback options row */}
|
{/* Playback options */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "column",
|
||||||
flexWrap: "wrap",
|
alignItems: "flex-start",
|
||||||
gap: 12,
|
gap: 10,
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { Image } from "expo-image";
|
|||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -40,6 +42,69 @@ const CredentialsSchema = z.object({
|
|||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const TVBackButton: React.FC<{ onPress: () => void; label: string }> = ({
|
||||||
|
onPress,
|
||||||
|
label,
|
||||||
|
}) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.05 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
}}
|
||||||
|
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: isFocused
|
||||||
|
? "rgba(168, 85, 247, 0.2)"
|
||||||
|
: "transparent",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isFocused ? Colors.primary : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-back'
|
||||||
|
size={28}
|
||||||
|
color={isFocused ? "#FFFFFF" : Colors.primary}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: isFocused ? "#FFFFFF" : Colors.primary,
|
||||||
|
fontSize: 20,
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const TVLogin: React.FC = () => {
|
export const TVLogin: React.FC = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -402,25 +467,10 @@ export const TVLogin: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<Pressable
|
<TVBackButton
|
||||||
onPress={() => removeServer()}
|
onPress={() => removeServer()}
|
||||||
style={{
|
label={t("login.change_server")}
|
||||||
flexDirection: "row",
|
/>
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-back'
|
|
||||||
size={28}
|
|
||||||
color={Colors.primary}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{ color: Colors.primary, fontSize: 20, marginLeft: 4 }}
|
|
||||||
>
|
|
||||||
{t("login.change_server")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { Animated, Easing, Pressable, Switch, View } from "react-native";
|
import { Animated, Easing, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
@@ -87,12 +87,25 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<View pointerEvents='none'>
|
<View
|
||||||
<Switch
|
pointerEvents='none'
|
||||||
value={value}
|
style={{
|
||||||
onValueChange={onValueChange}
|
width: 60,
|
||||||
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
height: 34,
|
||||||
thumbColor='white'
|
borderRadius: 17,
|
||||||
|
backgroundColor: value ? Colors.primary : "#3f3f46",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: "white",
|
||||||
|
alignSelf: value ? "flex-end" : "flex-start",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
141
components/series/TVEpisodeCard.tsx
Normal file
141
components/series/TVEpisodeCard.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
export const TV_EPISODE_WIDTH = 340;
|
||||||
|
|
||||||
|
interface TVEpisodeCardProps {
|
||||||
|
episode: BaseItemDto;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||||
|
episode,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
disabled = false,
|
||||||
|
onPress,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const thumbnailUrl = useMemo(() => {
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
// Try to get episode primary image first
|
||||||
|
if (episode.ImageTags?.Primary) {
|
||||||
|
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80&tag=${episode.ImageTags.Primary}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to series thumb or backdrop
|
||||||
|
if (episode.ParentBackdropItemId && episode.ParentThumbImageTag) {
|
||||||
|
return `${api.basePath}/Items/${episode.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${episode.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default episode image
|
||||||
|
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
|
}, [api, episode]);
|
||||||
|
|
||||||
|
const duration = useMemo(() => {
|
||||||
|
if (!episode.RunTimeTicks) return null;
|
||||||
|
return runtimeTicksToMinutes(episode.RunTimeTicks);
|
||||||
|
}, [episode.RunTimeTicks]);
|
||||||
|
|
||||||
|
const episodeLabel = useMemo(() => {
|
||||||
|
const season = episode.ParentIndexNumber;
|
||||||
|
const ep = episode.IndexNumber;
|
||||||
|
if (season !== undefined && ep !== undefined) {
|
||||||
|
return `S${season}:E${ep}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [episode.ParentIndexNumber, episode.IndexNumber]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ width: TV_EPISODE_WIDTH }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={onPress}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
disabled={disabled}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_EPISODE_WIDTH,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#262626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{thumbnailUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: thumbnailUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!episode.UserData?.Played && <WatchedIndicator item={episode} />}
|
||||||
|
<ProgressBar item={episode} />
|
||||||
|
</View>
|
||||||
|
</TVFocusablePoster>
|
||||||
|
|
||||||
|
{/* Episode info below thumbnail */}
|
||||||
|
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
|
{episodeLabel && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{episodeLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{duration && (
|
||||||
|
<>
|
||||||
|
<Text style={{ color: "#6B7280", fontSize: 14 }}>•</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: "#9CA3AF" }}>{duration}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginTop: 4,
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{episode.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
195
components/series/TVSeasonSelector.tsx
Normal file
195
components/series/TVSeasonSelector.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface TVSeasonSelectorProps {
|
||||||
|
visible: boolean;
|
||||||
|
seasons: BaseItemDto[];
|
||||||
|
selectedSeasonIndex: number | string | null | undefined;
|
||||||
|
onSelect: (seasonIndex: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVSeasonCard: React.FC<{
|
||||||
|
season: BaseItemDto;
|
||||||
|
isSelected: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}> = ({ season, isSelected, hasTVPreferredFocus, onPress }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
const seasonName = useMemo(() => {
|
||||||
|
if (season.Name) return season.Name;
|
||||||
|
if (season.IndexNumber !== undefined) return `Season ${season.IndexNumber}`;
|
||||||
|
return "Season";
|
||||||
|
}, [season]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
width: 180,
|
||||||
|
height: 85,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: isSelected
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: focused ? "#000" : "#fff",
|
||||||
|
fontWeight: focused || isSelected ? "600" : "400",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{seasonName}
|
||||||
|
</Text>
|
||||||
|
{isSelected && !focused && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={18}
|
||||||
|
color='rgba(255,255,255,0.8)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVSeasonSelector: React.FC<TVSeasonSelectorProps> = ({
|
||||||
|
visible,
|
||||||
|
seasons,
|
||||||
|
selectedSeasonIndex,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const initialFocusIndex = useMemo(() => {
|
||||||
|
const idx = seasons.findIndex(
|
||||||
|
(s) =>
|
||||||
|
s.IndexNumber === selectedSeasonIndex ||
|
||||||
|
s.Name === String(selectedSeasonIndex),
|
||||||
|
);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}, [seasons, selectedSeasonIndex]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={80}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.select_season")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Horizontal season cards */}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seasons.map((season, index) => (
|
||||||
|
<TVSeasonCard
|
||||||
|
key={season.Id || index}
|
||||||
|
season={season}
|
||||||
|
isSelected={
|
||||||
|
season.IndexNumber === selectedSeasonIndex ||
|
||||||
|
season.Name === String(selectedSeasonIndex)
|
||||||
|
}
|
||||||
|
hasTVPreferredFocus={index === initialFocusIndex}
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(season.IndexNumber ?? index);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
118
components/series/TVSeriesHeader.tsx
Normal file
118
components/series/TVSeriesHeader.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Dimensions, View } from "react-native";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
|
||||||
|
interface TVSeriesHeaderProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(() => {
|
||||||
|
if (!api || !item) return null;
|
||||||
|
return getLogoImageUrlById({ api, item });
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const yearString = useMemo(() => {
|
||||||
|
const startYear = item.StartDate
|
||||||
|
? new Date(item.StartDate).getFullYear()
|
||||||
|
: item.ProductionYear;
|
||||||
|
|
||||||
|
const endYear = item.EndDate ? new Date(item.EndDate).getFullYear() : null;
|
||||||
|
|
||||||
|
if (startYear && endYear) {
|
||||||
|
if (startYear === endYear) return String(startYear);
|
||||||
|
return `${startYear} - ${endYear}`;
|
||||||
|
}
|
||||||
|
if (startYear) return String(startYear);
|
||||||
|
return null;
|
||||||
|
}, [item.StartDate, item.EndDate, item.ProductionYear]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
{/* Logo or Title */}
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logoUrl }}
|
||||||
|
style={{
|
||||||
|
height: 100,
|
||||||
|
width: "80%",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
contentPosition='left'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 52,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata badges row */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{yearString && (
|
||||||
|
<Text style={{ color: "#9CA3AF", fontSize: 18 }}>{yearString}</Text>
|
||||||
|
)}
|
||||||
|
{item.OfficialRating && (
|
||||||
|
<Badge text={item.OfficialRating} variant='gray' />
|
||||||
|
)}
|
||||||
|
{item.CommunityRating != null && (
|
||||||
|
<Badge
|
||||||
|
text={item.CommunityRating.toFixed(1)}
|
||||||
|
variant='gray'
|
||||||
|
iconLeft={<Ionicons name='star' size={16} color='gold' />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{item.Genres && item.Genres.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<GenreTags genres={item.Genres} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
{item.Overview && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#D1D5DB",
|
||||||
|
lineHeight: 28,
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.45,
|
||||||
|
}}
|
||||||
|
numberOfLines={4}
|
||||||
|
>
|
||||||
|
{item.Overview}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
635
components/series/TVSeriesPage.tsx
Normal file
635
components/series/TVSeriesPage.tsx
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useSegments } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Easing,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { seasonIndexAtom } from "@/components/series/SeasonPicker";
|
||||||
|
import {
|
||||||
|
TV_EPISODE_WIDTH,
|
||||||
|
TVEpisodeCard,
|
||||||
|
} from "@/components/series/TVEpisodeCard";
|
||||||
|
import { TVSeasonSelector } from "@/components/series/TVSeasonSelector";
|
||||||
|
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import {
|
||||||
|
buildOfflineSeasons,
|
||||||
|
getDownloadedEpisodesForSeason,
|
||||||
|
} from "@/utils/downloads/offline-series";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 80;
|
||||||
|
const TOP_PADDING = 140;
|
||||||
|
const POSTER_WIDTH_PERCENT = 0.22;
|
||||||
|
const ITEM_GAP = 16;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
interface TVSeriesPageProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
allEpisodes?: BaseItemDto[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focusable button component for TV
|
||||||
|
const TVFocusableButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: "primary" | "secondary";
|
||||||
|
}> = ({
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
|
variant = "primary",
|
||||||
|
}) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
const isPrimary = variant === "primary";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: isPrimary ? "#fff" : "#a855f7",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: focused
|
||||||
|
? isPrimary
|
||||||
|
? "#ffffff"
|
||||||
|
: "#7c3aed"
|
||||||
|
: isPrimary
|
||||||
|
? "rgba(255, 255, 255, 0.9)"
|
||||||
|
: "rgba(124, 58, 237, 0.8)",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 18,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minWidth: 180,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Season selector button
|
||||||
|
const TVSeasonButton: React.FC<{
|
||||||
|
seasonName: string;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ seasonName, onPress, disabled = false }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 120,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.02);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 12 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#000" : "#FFFFFF",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seasonName}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-down'
|
||||||
|
size={18}
|
||||||
|
color={focused ? "#000" : "#FFFFFF"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||||
|
item,
|
||||||
|
allEpisodes = [],
|
||||||
|
isLoading: _isLoading,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { getDownloadedItems, downloadedItems } = useDownload();
|
||||||
|
|
||||||
|
// Season state
|
||||||
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
|
const selectedSeasonIndex = useMemo(
|
||||||
|
() => seasonIndexState[item.Id ?? ""] ?? 1,
|
||||||
|
[item.Id, seasonIndexState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [openModal, setOpenModal] = useState<"season" | null>(null);
|
||||||
|
const isModalOpen = openModal !== null;
|
||||||
|
|
||||||
|
// ScrollView ref for page scrolling
|
||||||
|
const mainScrollRef = useRef<ScrollView>(null);
|
||||||
|
// FlatList ref for scrolling back
|
||||||
|
const episodeListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||||
|
const [focusedCount, setFocusedCount] = useState(0);
|
||||||
|
const prevFocusedCount = useRef(0);
|
||||||
|
|
||||||
|
// Scroll back to start when episode list loses focus
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFocusedCount.current > 0 && focusedCount === 0) {
|
||||||
|
episodeListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
|
// Scroll page back to top when leaving episode section
|
||||||
|
mainScrollRef.current?.scrollTo({ y: 0, animated: true });
|
||||||
|
}
|
||||||
|
prevFocusedCount.current = focusedCount;
|
||||||
|
}, [focusedCount]);
|
||||||
|
|
||||||
|
const handleEpisodeFocus = useCallback(() => {
|
||||||
|
setFocusedCount((c) => {
|
||||||
|
// Scroll page down when first episode receives focus
|
||||||
|
if (c === 0) {
|
||||||
|
mainScrollRef.current?.scrollTo({ y: 200, animated: true });
|
||||||
|
}
|
||||||
|
return c + 1;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEpisodeBlur = useCallback(() => {
|
||||||
|
setFocusedCount((c) => Math.max(0, c - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch seasons
|
||||||
|
const { data: seasons = [] } = useQuery({
|
||||||
|
queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (isOffline) {
|
||||||
|
return buildOfflineSeasons(getDownloadedItems(), item.Id!);
|
||||||
|
}
|
||||||
|
if (!api || !user?.Id || !item.Id) return [];
|
||||||
|
|
||||||
|
const response = await api.axiosInstance.get(
|
||||||
|
`${api.basePath}/Shows/${item.Id}/Seasons`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
userId: user.Id,
|
||||||
|
itemId: item.Id,
|
||||||
|
Fields: "ItemCounts,PrimaryImageAspectRatio",
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get selected season ID
|
||||||
|
const selectedSeasonId = useMemo(() => {
|
||||||
|
const season = seasons.find(
|
||||||
|
(s: BaseItemDto) =>
|
||||||
|
s.IndexNumber === selectedSeasonIndex ||
|
||||||
|
s.Name === String(selectedSeasonIndex),
|
||||||
|
);
|
||||||
|
return season?.Id ?? null;
|
||||||
|
}, [seasons, selectedSeasonIndex]);
|
||||||
|
|
||||||
|
// Get selected season number for offline mode
|
||||||
|
const selectedSeasonNumber = useMemo(() => {
|
||||||
|
if (!isOffline) return null;
|
||||||
|
const season = seasons.find(
|
||||||
|
(s: BaseItemDto) =>
|
||||||
|
s.IndexNumber === selectedSeasonIndex ||
|
||||||
|
s.Name === String(selectedSeasonIndex),
|
||||||
|
);
|
||||||
|
return season?.IndexNumber ?? null;
|
||||||
|
}, [isOffline, seasons, selectedSeasonIndex]);
|
||||||
|
|
||||||
|
// Fetch episodes for selected season
|
||||||
|
const { data: episodesForSeason = [] } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"episodes",
|
||||||
|
item.Id,
|
||||||
|
isOffline ? selectedSeasonNumber : selectedSeasonId,
|
||||||
|
isOffline,
|
||||||
|
downloadedItems.length,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (isOffline) {
|
||||||
|
return getDownloadedEpisodesForSeason(
|
||||||
|
getDownloadedItems(),
|
||||||
|
item.Id!,
|
||||||
|
selectedSeasonNumber!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||||
|
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.Id,
|
||||||
|
userId: user.Id,
|
||||||
|
seasonId: selectedSeasonId,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
},
|
||||||
|
staleTime: isOffline ? Infinity : 0,
|
||||||
|
enabled: isOffline
|
||||||
|
? !!item.Id && selectedSeasonNumber !== null
|
||||||
|
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find next unwatched episode
|
||||||
|
const nextUnwatchedEpisode = useMemo(() => {
|
||||||
|
// First check all episodes for a "next up" candidate
|
||||||
|
for (const ep of allEpisodes) {
|
||||||
|
if (!ep.UserData?.Played) {
|
||||||
|
// Check if it has progress (continue watching)
|
||||||
|
if ((ep.UserData?.PlaybackPositionTicks ?? 0) > 0) {
|
||||||
|
return ep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first unwatched
|
||||||
|
return allEpisodes.find((ep) => !ep.UserData?.Played) || allEpisodes[0];
|
||||||
|
}, [allEpisodes]);
|
||||||
|
|
||||||
|
// Get season name for button
|
||||||
|
const selectedSeasonName = useMemo(() => {
|
||||||
|
const season = seasons.find(
|
||||||
|
(s: BaseItemDto) =>
|
||||||
|
s.IndexNumber === selectedSeasonIndex ||
|
||||||
|
s.Name === String(selectedSeasonIndex),
|
||||||
|
);
|
||||||
|
return season?.Name || `Season ${selectedSeasonIndex}`;
|
||||||
|
}, [seasons, selectedSeasonIndex]);
|
||||||
|
|
||||||
|
// Handle episode press
|
||||||
|
const handleEpisodePress = useCallback(
|
||||||
|
(episode: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(episode, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle play next episode
|
||||||
|
const handlePlayNextEpisode = useCallback(() => {
|
||||||
|
if (nextUnwatchedEpisode) {
|
||||||
|
const navigation = getItemNavigation(nextUnwatchedEpisode, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
}
|
||||||
|
}, [nextUnwatchedEpisode, from, router]);
|
||||||
|
|
||||||
|
// Handle season selection
|
||||||
|
const handleSeasonSelect = useCallback(
|
||||||
|
(seasonIdx: number) => {
|
||||||
|
if (!item.Id) return;
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.Id!]: seasonIdx,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[item.Id, setSeasonIndexState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Episode list item layout
|
||||||
|
const getItemLayout = useCallback(
|
||||||
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
|
length: TV_EPISODE_WIDTH + ITEM_GAP,
|
||||||
|
offset: (TV_EPISODE_WIDTH + ITEM_GAP) * index,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render episode card
|
||||||
|
const renderEpisode = useCallback(
|
||||||
|
({ item: episode }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<View style={{ marginRight: ITEM_GAP }}>
|
||||||
|
<TVEpisodeCard
|
||||||
|
episode={episode}
|
||||||
|
onPress={() => handleEpisodePress(episode)}
|
||||||
|
disabled={isModalOpen}
|
||||||
|
onFocus={handleEpisodeFocus}
|
||||||
|
onBlur={handleEpisodeBlur}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[handleEpisodePress, isModalOpen, handleEpisodeFocus, handleEpisodeBlur],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get play button text
|
||||||
|
const playButtonText = useMemo(() => {
|
||||||
|
if (!nextUnwatchedEpisode) return t("common.play");
|
||||||
|
|
||||||
|
const season = nextUnwatchedEpisode.ParentIndexNumber;
|
||||||
|
const episode = nextUnwatchedEpisode.IndexNumber;
|
||||||
|
const hasProgress =
|
||||||
|
(nextUnwatchedEpisode.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||||
|
|
||||||
|
if (hasProgress) {
|
||||||
|
return `${t("home.continue")} S${season}:E${episode}`;
|
||||||
|
}
|
||||||
|
return `${t("common.play")} S${season}:E${episode}`;
|
||||||
|
}, [nextUnwatchedEpisode, t]);
|
||||||
|
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
{/* Full-screen backdrop */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
variant='Backdrop'
|
||||||
|
item={item}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
{/* Gradient overlays for readability */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.5, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.8)", "transparent"]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0.6, y: 0 }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "60%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<ScrollView
|
||||||
|
ref={mainScrollRef}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Top section - Poster + Content */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
minHeight: SCREEN_HEIGHT * 0.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side - Poster */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: SCREEN_WIDTH * POSTER_WIDTH_PERCENT,
|
||||||
|
marginRight: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
variant='Primary'
|
||||||
|
item={item}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Content */}
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
<TVSeriesHeader item={item} />
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusableButton
|
||||||
|
onPress={handlePlayNextEpisode}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
disabled={isModalOpen}
|
||||||
|
variant='primary'
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='play'
|
||||||
|
size={28}
|
||||||
|
color='#000000'
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#000000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{playButtonText}
|
||||||
|
</Text>
|
||||||
|
</TVFocusableButton>
|
||||||
|
|
||||||
|
{seasons.length > 1 && (
|
||||||
|
<TVSeasonButton
|
||||||
|
seasonName={selectedSeasonName}
|
||||||
|
onPress={() => setOpenModal("season")}
|
||||||
|
disabled={isModalOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Episodes section */}
|
||||||
|
<View style={{ marginTop: 40, overflow: "visible" }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedSeasonName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
ref={episodeListRef}
|
||||||
|
horizontal
|
||||||
|
data={episodesForSeason}
|
||||||
|
keyExtractor={(ep) => ep.Id!}
|
||||||
|
renderItem={renderEpisode}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={5}
|
||||||
|
maxToRenderPerBatch={3}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#737373",
|
||||||
|
fontSize: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.no_episodes_for_this_season")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Season selector modal */}
|
||||||
|
<TVSeasonSelector
|
||||||
|
visible={openModal === "season"}
|
||||||
|
seasons={seasons}
|
||||||
|
selectedSeasonIndex={selectedSeasonIndex}
|
||||||
|
onSelect={handleSeasonSelect}
|
||||||
|
onClose={() => setOpenModal(null)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -14,6 +17,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
Image,
|
||||||
Pressable,
|
Pressable,
|
||||||
Animated as RNAnimated,
|
Animated as RNAnimated,
|
||||||
Easing as RNEasing,
|
Easing as RNEasing,
|
||||||
@@ -23,7 +27,9 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
|
cancelAnimation,
|
||||||
Easing,
|
Easing,
|
||||||
|
runOnJS,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
useAnimatedReaction,
|
useAnimatedReaction,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -32,7 +38,13 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { formatTimeString, ticksToMs } from "@/utils/time";
|
import { formatTimeString, ticksToMs } from "@/utils/time";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||||
@@ -480,6 +492,159 @@ const selectorStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Keep onFinish ref updated
|
||||||
|
onFinishRef.current = onFinish;
|
||||||
|
|
||||||
|
// Get episode thumbnail
|
||||||
|
const imageUrl = getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item: nextItem,
|
||||||
|
width: 360, // 2x for retina
|
||||||
|
quality: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle animation based on show and isPlaying state
|
||||||
|
useEffect(() => {
|
||||||
|
if (show && isPlaying) {
|
||||||
|
// Start/restart animation from beginning
|
||||||
|
progress.value = 0;
|
||||||
|
progress.value = withTiming(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
duration: 8000, // 8 seconds (ends 2 seconds before episode end)
|
||||||
|
easing: Easing.linear,
|
||||||
|
},
|
||||||
|
(finished) => {
|
||||||
|
if (finished && onFinishRef.current) {
|
||||||
|
runOnJS(onFinishRef.current)();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Pause: cancel animation and reset progress
|
||||||
|
cancelAnimation(progress);
|
||||||
|
progress.value = 0;
|
||||||
|
}
|
||||||
|
}, [show, isPlaying, progress]);
|
||||||
|
|
||||||
|
// Animated style for progress bar
|
||||||
|
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}>
|
||||||
|
{/* Episode Thumbnail - left side */}
|
||||||
|
{imageUrl && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={countdownStyles.thumbnail}
|
||||||
|
resizeMode='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content - right side */}
|
||||||
|
<View style={countdownStyles.content}>
|
||||||
|
{/* Label: "Next Episode" */}
|
||||||
|
<Text style={countdownStyles.label}>
|
||||||
|
{t("player.next_episode")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Series Name */}
|
||||||
|
<Text style={countdownStyles.seriesName} numberOfLines={1}>
|
||||||
|
{nextItem.SeriesName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Episode Info: S#E# - Episode Name */}
|
||||||
|
<Text style={countdownStyles.episodeInfo} numberOfLines={1}>
|
||||||
|
S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
|
||||||
|
{nextItem.Name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<View style={countdownStyles.progressContainer}>
|
||||||
|
<Animated.View
|
||||||
|
style={[countdownStyles.progressBar, progressStyle]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const countdownStyles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 140,
|
||||||
|
right: 48,
|
||||||
|
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> = ({
|
export const Controls: FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
seek,
|
seek,
|
||||||
@@ -500,6 +665,21 @@ export const Controls: FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// TV is always online
|
||||||
|
const { nextItem } = usePlaybackManager({ item, isOffline: false });
|
||||||
|
|
||||||
// Modal state for option selectors
|
// Modal state for option selectors
|
||||||
// "settings" shows the settings panel, "audio"/"subtitle" for direct selection
|
// "settings" shows the settings panel, "audio"/"subtitle" for direct selection
|
||||||
@@ -748,6 +928,66 @@ export const Controls: FC<Props> = ({
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// goToNextItem function for auto-play
|
||||||
|
const goToNextItem = useCallback(
|
||||||
|
({ 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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show countdown? (TV always auto-plays next episode, no episode count limit)
|
||||||
|
const shouldShowCountdown = useMemo(() => {
|
||||||
|
if (!nextItem) return false;
|
||||||
|
if (item?.Type !== "Episode") return false;
|
||||||
|
return remainingTime > 0 && remainingTime <= 10000;
|
||||||
|
}, [nextItem, item, remainingTime]);
|
||||||
|
|
||||||
|
// Handler for when countdown animation finishes
|
||||||
|
const handleAutoPlayFinish = useCallback(() => {
|
||||||
|
goToNextItem({ isAutoPlay: true });
|
||||||
|
}, [goToNextItem]);
|
||||||
|
|
||||||
// Check if we have any settings to show
|
// Check if we have any settings to show
|
||||||
const hasSettings =
|
const hasSettings =
|
||||||
audioTracks.length > 0 ||
|
audioTracks.length > 0 ||
|
||||||
@@ -756,6 +996,12 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.controlsContainer} pointerEvents='box-none'>
|
<View style={styles.controlsContainer} pointerEvents='box-none'>
|
||||||
|
{/* Dark tint overlay when controls are visible */}
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.darkOverlay, bottomAnimatedStyle]}
|
||||||
|
pointerEvents='none'
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Center Play Button - shown when paused */}
|
{/* Center Play Button - shown when paused */}
|
||||||
{!isPlaying && showControls && (
|
{!isPlaying && showControls && (
|
||||||
<View style={styles.centerContainer}>
|
<View style={styles.centerContainer}>
|
||||||
@@ -772,6 +1018,17 @@ export const Controls: FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Next Episode Countdown - always visible when countdown active */}
|
||||||
|
{nextItem && (
|
||||||
|
<TVNextEpisodeCountdown
|
||||||
|
nextItem={nextItem}
|
||||||
|
api={api}
|
||||||
|
show={shouldShowCountdown}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onFinish={handleAutoPlayFinish}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top hint - swipe up for settings */}
|
{/* Top hint - swipe up for settings */}
|
||||||
{showControls && hasSettings && !isModalOpen && (
|
{showControls && hasSettings && !isModalOpen && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -794,7 +1051,7 @@ export const Controls: FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-down'
|
name='chevron-down'
|
||||||
size={16}
|
size={20}
|
||||||
color='rgba(255,255,255,0.5)'
|
color='rgba(255,255,255,0.5)'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -820,9 +1077,9 @@ export const Controls: FC<Props> = ({
|
|||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<View style={styles.metadataContainer}>
|
<View style={styles.metadataContainer}>
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
<Text style={styles.subtitleText}>
|
<Text
|
||||||
{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
|
style={styles.subtitleText}
|
||||||
</Text>
|
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.titleText}>{item?.Name}</Text>
|
<Text style={styles.titleText}>{item?.Name}</Text>
|
||||||
{item?.Type === "Movie" && (
|
{item?.Type === "Movie" && (
|
||||||
@@ -924,6 +1181,14 @@ const styles = StyleSheet.create({
|
|||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
},
|
},
|
||||||
|
darkOverlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||||
|
},
|
||||||
centerContainer: {
|
centerContainer: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -1022,6 +1287,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
settingsHintText: {
|
settingsHintText: {
|
||||||
color: "rgba(255,255,255,0.5)",
|
color: "rgba(255,255,255,0.5)",
|
||||||
fontSize: 14,
|
fontSize: 16,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -626,6 +626,7 @@
|
|||||||
"series": "Series",
|
"series": "Series",
|
||||||
"seasons": "Seasons",
|
"seasons": "Seasons",
|
||||||
"season": "Season",
|
"season": "Season",
|
||||||
|
"select_season": "Select Season",
|
||||||
"no_episodes_for_this_season": "No episodes for this season",
|
"no_episodes_for_this_season": "No episodes for this season",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"more_with": "More with {{name}}",
|
"more_with": "More with {{name}}",
|
||||||
|
|||||||
Reference in New Issue
Block a user