mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-09 22:12:54 +01:00
Compare commits
4 Commits
chore/remo
...
feat/count
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00cdbb1b8f | ||
|
|
4db89ec916 | ||
|
|
0bb7aa68ae | ||
|
|
d1637a778e |
99
components/TrackSheet.tsx
Normal file
99
components/TrackSheet.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { FilterSheet } from "./filters/FilterSheet";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
source?: MediaSourceInfo;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
selected?: number | undefined;
|
||||||
|
streamType?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrackSheet: React.FC<Props> = ({
|
||||||
|
source,
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
streamType,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const streams = useMemo(
|
||||||
|
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||||
|
[source, streamType],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedSteam = useMemo(
|
||||||
|
() => streams?.find((x) => x.Index === selected),
|
||||||
|
[streams, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const noneOption = useMemo(
|
||||||
|
() => ({ Index: -1, DisplayTitle: t("common.none") }),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Creates a modified data array that includes a "None" option for subtitles
|
||||||
|
// We might want to possibly do this for other places, like audio?
|
||||||
|
const addNoneToSubtitles = useMemo(() => {
|
||||||
|
if (streamType === "Subtitle") {
|
||||||
|
const result = streams ? [noneOption, ...streams] : [noneOption];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return streams;
|
||||||
|
}, [streams, streamType, noneOption]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (isTv || (streams && streams.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||||
|
<View className='flex flex-col'>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{selected === -1 && streamType === "Subtitle"
|
||||||
|
? t("common.none")
|
||||||
|
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
title={title}
|
||||||
|
data={addNoneToSubtitles || []}
|
||||||
|
values={
|
||||||
|
selected === -1 && streamType === "Subtitle"
|
||||||
|
? [{ Index: -1, DisplayTitle: t("common.none") }]
|
||||||
|
: selectedSteam
|
||||||
|
? [selectedSteam]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
multiple={false}
|
||||||
|
searchFilter={(item, query) => {
|
||||||
|
const label = (item as any).DisplayTitle || "";
|
||||||
|
return label.toLowerCase().includes(query.toLowerCase());
|
||||||
|
}}
|
||||||
|
renderItemLabel={(item) => (
|
||||||
|
<Text>{(item as any).DisplayTitle || ""}</Text>
|
||||||
|
)}
|
||||||
|
set={(vals) => {
|
||||||
|
const chosen = vals[0] as any;
|
||||||
|
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
|
||||||
|
onChange(chosen.Index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,43 +1,116 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import React from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View, type ViewStyle } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
const isAggregateType = (item: BaseItemDto) =>
|
||||||
if (Platform.isTV) {
|
item.Type === "Series" || item.Type === "BoxSet";
|
||||||
// TV: Show white checkmark when watched
|
|
||||||
if (
|
// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
|
||||||
item.UserData?.Played &&
|
const tvBadgeBase: ViewStyle = {
|
||||||
(item.Type === "Movie" || item.Type === "Episode")
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 8,
|
top: scaleSize(8),
|
||||||
right: 8,
|
right: scaleSize(8),
|
||||||
backgroundColor: "rgba(255,255,255,0.9)",
|
height: scaleSize(28),
|
||||||
borderRadius: 14,
|
borderRadius: scaleSize(14),
|
||||||
width: 28,
|
backgroundColor: "rgba(255,255,255,0.92)",
|
||||||
height: 28,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mobile uses raw dp — no scaling.
|
||||||
|
const mobileBadgeBase: ViewStyle = {
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#9333ea",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the unplayed-episode count badge for Series/BoxSet items that still
|
||||||
|
* have episodes left to watch. Returns null for non-aggregate types, fully
|
||||||
|
* watched items, or items with no unplayed count, so it is safe to mount
|
||||||
|
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
|
||||||
|
* the watched checkmark is drawn natively and only the count needs RN).
|
||||||
|
*/
|
||||||
|
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||||
|
({ item }) => {
|
||||||
|
if (!isAggregateType(item)) return null;
|
||||||
|
if (item.UserData?.Played) return null;
|
||||||
|
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
|
||||||
|
if (unplayed <= 0) return null;
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
tvBadgeBase,
|
||||||
|
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: scaleSize(15),
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "black",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name='checkmark' size={18} color='black' />
|
{unplayed}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
return (
|
||||||
|
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
|
||||||
|
{unplayed}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
|
||||||
|
const isAggregate = isAggregateType(item);
|
||||||
|
const isPlayed = item.UserData?.Played === true;
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
|
// Fully watched → white checkmark badge (top-right)
|
||||||
|
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
|
||||||
|
return (
|
||||||
|
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
|
||||||
|
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Series/BoxSet with remaining episodes → count badge
|
||||||
|
return <UnplayedCountBadge item={item} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: Show purple triangle for unwatched
|
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{item.UserData?.Played === false &&
|
{isMovieOrEpisode && !isPlayed && (
|
||||||
(item.Type === "Movie" || item.Type === "Episode") && (
|
|
||||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fully watched Series/BoxSet → small purple checkmark */}
|
||||||
|
{isAggregate && isPlayed && (
|
||||||
|
<View style={[mobileBadgeBase, { width: 20 }]}>
|
||||||
|
<Ionicons name='checkmark' size={13} color='white' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Series/BoxSet with remaining episodes → count badge */}
|
||||||
|
<UnplayedCountBadge item={item} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
import { ItemImage } from "../common/ItemImage";
|
import { ItemImage } from "../common/ItemImage";
|
||||||
import { WatchedIndicator } from "../WatchedIndicator";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Image } from "expo-image";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { ProgressBar } from "@/components/common/ProgressBar";
|
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
import {
|
||||||
|
UnplayedCountBadge,
|
||||||
|
WatchedIndicator,
|
||||||
|
} from "@/components/WatchedIndicator";
|
||||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import {
|
import {
|
||||||
@@ -427,6 +430,12 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
/>
|
/>
|
||||||
{PlayButtonOverlay}
|
{PlayButtonOverlay}
|
||||||
{NowPlayingBadge}
|
{NowPlayingBadge}
|
||||||
|
{/*
|
||||||
|
The glass view draws the watched checkmark natively but cannot show
|
||||||
|
an unplayed-episode count, so render it as an RN overlay on top.
|
||||||
|
Returns null when not applicable (non-series / fully watched).
|
||||||
|
*/}
|
||||||
|
<UnplayedCountBadge item={item} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
560
components/video-player/controls/TVSubtitleSheet.tsx
Normal file
560
components/video-player/controls/TVSubtitleSheet.tsx
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaStream,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import {
|
||||||
|
TVCancelButton,
|
||||||
|
TVLanguageCard,
|
||||||
|
TVSubtitleResultCard,
|
||||||
|
TVTabButton,
|
||||||
|
TVTrackCard,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import {
|
||||||
|
type SubtitleSearchResult,
|
||||||
|
useRemoteSubtitles,
|
||||||
|
} from "@/hooks/useRemoteSubtitles";
|
||||||
|
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||||
|
|
||||||
|
interface TVSubtitleSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
item: BaseItemDto;
|
||||||
|
mediaSourceId?: string | null;
|
||||||
|
subtitleTracks: MediaStream[];
|
||||||
|
currentSubtitleIndex: number;
|
||||||
|
onSubtitleIndexChange: (index: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onServerSubtitleDownloaded?: () => void;
|
||||||
|
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = "tracks" | "download";
|
||||||
|
|
||||||
|
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||||
|
visible,
|
||||||
|
item,
|
||||||
|
mediaSourceId,
|
||||||
|
subtitleTracks,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
onSubtitleIndexChange,
|
||||||
|
onClose,
|
||||||
|
onServerSubtitleDownloaded,
|
||||||
|
onLocalSubtitleDownloaded,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[TVSubtitleSheet] visible:",
|
||||||
|
visible,
|
||||||
|
"tracks:",
|
||||||
|
subtitleTracks.length,
|
||||||
|
);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
||||||
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||||
|
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [isTabContentReady, setIsTabContentReady] = useState(false);
|
||||||
|
const firstTrackRef = useRef<View>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
hasOpenSubtitlesApiKey,
|
||||||
|
isSearching,
|
||||||
|
searchError,
|
||||||
|
searchResults,
|
||||||
|
search,
|
||||||
|
downloadAsync,
|
||||||
|
reset,
|
||||||
|
} = useRemoteSubtitles({
|
||||||
|
itemId: item.Id ?? "",
|
||||||
|
item,
|
||||||
|
mediaSourceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetRef = useRef(reset);
|
||||||
|
resetRef.current = reset;
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||||
|
|
||||||
|
const initialSelectedTrackIndex = useMemo(() => {
|
||||||
|
if (currentSubtitleIndex === -1) return 0;
|
||||||
|
const trackIdx = subtitleTracks.findIndex(
|
||||||
|
(t) => t.Index === currentSubtitleIndex,
|
||||||
|
);
|
||||||
|
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||||
|
}, [subtitleTracks, currentSubtitleIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(300);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setHasSearchedThisSession(false);
|
||||||
|
setActiveTab("tracks");
|
||||||
|
resetRef.current();
|
||||||
|
setIsReady(false);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && activeTab === "download" && !hasSearchedThisSession) {
|
||||||
|
search({ language: selectedLanguage });
|
||||||
|
setHasSearchedThisSession(true);
|
||||||
|
}
|
||||||
|
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady) {
|
||||||
|
setIsTabContentReady(false);
|
||||||
|
const timer = setTimeout(() => setIsTabContentReady(true), 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsTabContentReady(false);
|
||||||
|
}, [activeTab, isReady]);
|
||||||
|
|
||||||
|
const handleLanguageSelect = useCallback(
|
||||||
|
(code: string) => {
|
||||||
|
setSelectedLanguage(code);
|
||||||
|
search({ language: code });
|
||||||
|
},
|
||||||
|
[search],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTrackSelect = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
onSubtitleIndexChange(index);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[onSubtitleIndexChange, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(
|
||||||
|
async (result: SubtitleSearchResult) => {
|
||||||
|
setDownloadingId(result.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const downloadResult = await downloadAsync(result);
|
||||||
|
|
||||||
|
if (downloadResult.type === "server") {
|
||||||
|
onServerSubtitleDownloaded?.();
|
||||||
|
} else if (downloadResult.type === "local" && downloadResult.path) {
|
||||||
|
onLocalSubtitleDownloaded?.(downloadResult.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to download subtitle:", error);
|
||||||
|
} finally {
|
||||||
|
setDownloadingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
downloadAsync,
|
||||||
|
onServerSubtitleDownloaded,
|
||||||
|
onLocalSubtitleDownloaded,
|
||||||
|
onClose,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayLanguages = useMemo(
|
||||||
|
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const trackOptions = useMemo(() => {
|
||||||
|
const noneOption = {
|
||||||
|
label: t("item_card.subtitles.none"),
|
||||||
|
sublabel: undefined as string | undefined,
|
||||||
|
value: -1,
|
||||||
|
selected: currentSubtitleIndex === -1,
|
||||||
|
};
|
||||||
|
const options = subtitleTracks.map((track) => ({
|
||||||
|
label:
|
||||||
|
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||||
|
sublabel: track.Codec?.toUpperCase(),
|
||||||
|
value: track.Index!,
|
||||||
|
selected: track.Index === currentSubtitleIndex,
|
||||||
|
}));
|
||||||
|
return [noneOption, ...options];
|
||||||
|
}, [subtitleTracks, currentSubtitleIndex, t]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
{/* Header with tabs */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{t("item_card.subtitles.label") || "Subtitles"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<View style={styles.tabRow}>
|
||||||
|
<TVTabButton
|
||||||
|
label={t("item_card.subtitles.tracks") || "Tracks"}
|
||||||
|
active={activeTab === "tracks"}
|
||||||
|
onSelect={() => setActiveTab("tracks")}
|
||||||
|
/>
|
||||||
|
<TVTabButton
|
||||||
|
label={t("player.download") || "Download"}
|
||||||
|
active={activeTab === "download"}
|
||||||
|
onSelect={() => setActiveTab("download")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tracks Tab Content */}
|
||||||
|
{activeTab === "tracks" && isTabContentReady && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.tracksScroll}
|
||||||
|
contentContainerStyle={styles.tracksScrollContent}
|
||||||
|
>
|
||||||
|
{trackOptions.map((option, index) => (
|
||||||
|
<TVTrackCard
|
||||||
|
key={option.value}
|
||||||
|
ref={
|
||||||
|
index === initialSelectedTrackIndex
|
||||||
|
? firstTrackRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={option.label}
|
||||||
|
sublabel={option.sublabel}
|
||||||
|
selected={option.selected}
|
||||||
|
hasTVPreferredFocus={index === initialSelectedTrackIndex}
|
||||||
|
onPress={() => handleTrackSelect(option.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download Tab Content */}
|
||||||
|
{activeTab === "download" && isTabContentReady && (
|
||||||
|
<>
|
||||||
|
{/* Language Selector */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
{t("player.language") || "Language"}
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.languageScroll}
|
||||||
|
contentContainerStyle={styles.languageScrollContent}
|
||||||
|
>
|
||||||
|
{displayLanguages.map((lang, index) => (
|
||||||
|
<TVLanguageCard
|
||||||
|
key={lang.code}
|
||||||
|
code={lang.code}
|
||||||
|
name={lang.name}
|
||||||
|
selected={selectedLanguage === lang.code}
|
||||||
|
hasTVPreferredFocus={
|
||||||
|
index === 0 &&
|
||||||
|
(!searchResults || searchResults.length === 0)
|
||||||
|
}
|
||||||
|
onPress={() => handleLanguageSelect(lang.code)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Results Section */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
{t("player.results") || "Results"}
|
||||||
|
{searchResults && ` (${searchResults.length})`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isSearching && (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size='large' color='#fff' />
|
||||||
|
<Text style={styles.loadingText}>
|
||||||
|
{t("player.searching") || "Searching..."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{searchError && !isSearching && (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name='alert-circle-outline'
|
||||||
|
size={32}
|
||||||
|
color='rgba(255,100,100,0.8)'
|
||||||
|
/>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("player.search_failed") || "Search failed"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.errorHint}>
|
||||||
|
{!hasOpenSubtitlesApiKey
|
||||||
|
? t("player.no_subtitle_provider") ||
|
||||||
|
"No subtitle provider configured on server"
|
||||||
|
: String(searchError)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results */}
|
||||||
|
{searchResults &&
|
||||||
|
searchResults.length === 0 &&
|
||||||
|
!isSearching &&
|
||||||
|
!searchError && (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name='document-text-outline'
|
||||||
|
size={32}
|
||||||
|
color='rgba(255,255,255,0.4)'
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{t("player.no_subtitles_found") ||
|
||||||
|
"No subtitles found"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results list */}
|
||||||
|
{searchResults &&
|
||||||
|
searchResults.length > 0 &&
|
||||||
|
!isSearching && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.resultsScroll}
|
||||||
|
contentContainerStyle={styles.resultsScrollContent}
|
||||||
|
>
|
||||||
|
{searchResults.map((result, index) => (
|
||||||
|
<TVSubtitleResultCard
|
||||||
|
key={result.id}
|
||||||
|
result={result}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
isDownloading={downloadingId === result.id}
|
||||||
|
onPress={() => handleDownload(result)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* API Key hint if no fallback available */}
|
||||||
|
{!hasOpenSubtitlesApiKey && (
|
||||||
|
<View style={styles.apiKeyHint}>
|
||||||
|
<Ionicons
|
||||||
|
name='information-circle-outline'
|
||||||
|
size={16}
|
||||||
|
color='rgba(255,255,255,0.4)'
|
||||||
|
/>
|
||||||
|
<Text style={styles.apiKeyHintText}>
|
||||||
|
{t("player.add_opensubtitles_key_hint") ||
|
||||||
|
"Add OpenSubtitles API key in settings for client-side fallback"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel button */}
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.cancelButtonContainer}>
|
||||||
|
<TVCancelButton
|
||||||
|
onPress={onClose}
|
||||||
|
label={t("common.cancel") || "Cancel"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
maxHeight: "70%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 48,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
tabRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 24,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
},
|
||||||
|
tracksScroll: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
tracksScrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
languageScroll: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
languageScrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
resultsScroll: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
resultsScrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "rgba(255,100,100,0.9)",
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
errorHint: {
|
||||||
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
apiKeyHint: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
apiKeyHintText: {
|
||||||
|
color: "rgba(255,255,255,0.4)",
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
cancelButtonContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingTop: 20,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -484,6 +484,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No results",
|
"no_results": "No results",
|
||||||
|
"select": "Select",
|
||||||
"no_trailer_available": "No trailer available",
|
"no_trailer_available": "No trailer available",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
|
|||||||
Reference in New Issue
Block a user