mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-07 04:53:01 +01:00
Compare commits
3 Commits
chore/remo
...
fix/tv-aud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03ae18e3c0 | ||
|
|
271ed84811 | ||
|
|
ab90a1a52e |
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.6"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- 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.6"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- 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.6"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- 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.6"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -893,6 +893,27 @@ export default function DirectPlayerPage() {
|
|||||||
// Check if we're transcoding
|
// Check if we're transcoding
|
||||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
|
// A transcoded stream only carries the audio track the server encoded
|
||||||
|
// into it — switching requires re-negotiating the stream with the new
|
||||||
|
// index (like the mobile menu's replacePlayer), not an mpv aid change.
|
||||||
|
if (isTranscoding) {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item?.Id ?? "",
|
||||||
|
audioIndex: String(index),
|
||||||
|
subtitleIndex: String(currentSubtitleIndex),
|
||||||
|
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: bitrateValue?.toString() ?? "",
|
||||||
|
playbackPosition: msToTicks(progress.get()).toString(),
|
||||||
|
}).toString();
|
||||||
|
// Destroy the current mpv instance BEFORE navigating, same rationale as
|
||||||
|
// goToNextItem/goToPreviousItem: Expo Router briefly holds two players
|
||||||
|
// during the transition, and two simultaneous decoders OOM-kill low-RAM
|
||||||
|
// devices. Resume is preserved via the playbackPosition param.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Convert Jellyfin index to MPV track ID
|
// Convert Jellyfin index to MPV track ID
|
||||||
const mpvTrackId = getMpvAudioId(
|
const mpvTrackId = getMpvAudioId(
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
@@ -904,7 +925,14 @@ export default function DirectPlayerPage() {
|
|||||||
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[stream?.mediaSource],
|
[
|
||||||
|
stream?.mediaSource,
|
||||||
|
item?.Id,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
bitrateValue,
|
||||||
|
router,
|
||||||
|
progress,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// TV subtitle track change handler
|
// TV subtitle track change handler
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
|
InteractionManager,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TVFocusGuideView,
|
TVFocusGuideView,
|
||||||
@@ -75,6 +76,20 @@ export default function TVOptionModal() {
|
|||||||
}, [isReady]);
|
}, [isReady]);
|
||||||
|
|
||||||
const handleSelect = (value: any) => {
|
const handleSelect = (value: any) => {
|
||||||
|
if (modalState?.deferApplyUntilDismissed) {
|
||||||
|
// onSelect navigates (the transcode audio switch replacing the player);
|
||||||
|
// a router.replace fired while this modal is the active route would be
|
||||||
|
// swallowed. Close FIRST, apply after dismissal.
|
||||||
|
const onSelect = modalState.onSelect;
|
||||||
|
store.set(tvOptionModalAtom, null);
|
||||||
|
router.back();
|
||||||
|
InteractionManager.runAfterInteractions(() => onSelect?.(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// State-only callers (detail page, library filters, settings): run before
|
||||||
|
// closing so the re-render happens while the modal is up. Deferring it until
|
||||||
|
// after dismissal re-renders the page after focus returns and yanks TV
|
||||||
|
// focus, leaving navigation stuck.
|
||||||
modalState?.onSelect(value);
|
modalState?.onSelect(value);
|
||||||
store.set(tvOptionModalAtom, null);
|
store.set(tvOptionModalAtom, null);
|
||||||
router.back();
|
router.back();
|
||||||
|
|||||||
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.20.0",
|
"expo-doctor": "1.19.9",
|
||||||
"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.20.0", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-YL0uGQGZ65tWOCOiDH6BFzBkx/9N46MyBUdRfa4t6iuG4tbLg/phRrhmuaNm99QJVMqYtt3ksRbFCzuYOuX20Q=="],
|
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -564,6 +564,9 @@ export const Controls: FC<Props> = ({
|
|||||||
title: t("item_card.audio"),
|
title: t("item_card.audio"),
|
||||||
options: audioOptions,
|
options: audioOptions,
|
||||||
onSelect: handleAudioChange,
|
onSelect: handleAudioChange,
|
||||||
|
// In-player audio selection navigates (replacePlayer while transcoding);
|
||||||
|
// apply it after the modal is dismissed so it isn't swallowed.
|
||||||
|
deferApplyUntilDismissed: true,
|
||||||
});
|
});
|
||||||
controlsInteractionRef.current();
|
controlsInteractionRef.current();
|
||||||
}, [showOptions, t, audioOptions, handleAudioChange]);
|
}, [showOptions, t, audioOptions, handleAudioChange]);
|
||||||
|
|||||||
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ interface ShowOptionsParams<T> {
|
|||||||
onSelect: (value: T) => void;
|
onSelect: (value: T) => void;
|
||||||
cardWidth?: number;
|
cardWidth?: number;
|
||||||
cardHeight?: number;
|
cardHeight?: number;
|
||||||
|
deferApplyUntilDismissed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTVOptionModal = () => {
|
export const useTVOptionModal = () => {
|
||||||
@@ -26,6 +27,7 @@ export const useTVOptionModal = () => {
|
|||||||
onSelect: params.onSelect,
|
onSelect: params.onSelect,
|
||||||
cardWidth: params.cardWidth,
|
cardWidth: params.cardWidth,
|
||||||
cardHeight: params.cardHeight,
|
cardHeight: params.cardHeight,
|
||||||
|
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
|
||||||
});
|
});
|
||||||
router.push("/(auth)/tv-option-modal");
|
router.push("/(auth)/tv-option-modal");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.20.0",
|
"expo-doctor": "1.19.9",
|
||||||
"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,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",
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export type TVOptionModalState = {
|
|||||||
onSelect: (value: any) => void;
|
onSelect: (value: any) => void;
|
||||||
cardWidth?: number;
|
cardWidth?: number;
|
||||||
cardHeight?: number;
|
cardHeight?: number;
|
||||||
|
/**
|
||||||
|
* Run onSelect AFTER the modal route is dismissed. Needed only when onSelect
|
||||||
|
* navigates (the in-player audio switch replacing the player while
|
||||||
|
* transcoding), which the still-active modal route would otherwise swallow.
|
||||||
|
* Default (false) runs onSelect before closing, so state-only callers (detail
|
||||||
|
* page, library filters, settings) don't re-render after focus returns and
|
||||||
|
* lose TV focus.
|
||||||
|
*/
|
||||||
|
deferApplyUntilDismissed?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
export const tvOptionModalAtom = atom<TVOptionModalState>(null);
|
export const tvOptionModalAtom = atom<TVOptionModalState>(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user