mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-07 04:53:01 +01:00
Compare commits
7 Commits
fix/tv-aud
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e530d47a97 | ||
|
|
ed8744389b | ||
|
|
8f77704793 | ||
|
|
b879026745 | ||
|
|
888b735c42 | ||
|
|
a5daa5740e | ||
|
|
3950c01513 |
8
.github/workflows/build-apps.yml
vendored
8
.github/workflows/build-apps.yml
vendored
@@ -263,7 +263,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.5"
|
xcode-version: "26.6"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
@@ -335,7 +335,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.5"
|
xcode-version: "26.6"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
@@ -402,7 +402,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.5"
|
xcode-version: "26.6"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||||
@@ -472,7 +472,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.5"
|
xcode-version: "26.6"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -109,7 +109,7 @@
|
|||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.9",
|
"expo-doctor": "1.20.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.8",
|
"lint-staged": "17.0.8",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
@@ -1015,7 +1015,7 @@
|
|||||||
|
|
||||||
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
||||||
|
|
||||||
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
|
"expo-doctor": ["expo-doctor@1.20.0", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-YL0uGQGZ65tWOCOiDH6BFzBkx/9N46MyBUdRfa4t6iuG4tbLg/phRrhmuaNm99QJVMqYtt3ksRbFCzuYOuX20Q=="],
|
||||||
|
|
||||||
"expo-file-system": ["expo-file-system@56.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ=="],
|
"expo-file-system": ["expo-file-system@56.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
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,560 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.19.9",
|
"expo-doctor": "1.20.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.8",
|
"lint-staged": "17.0.8",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
|
|||||||
@@ -484,7 +484,6 @@
|
|||||||
},
|
},
|
||||||
"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