feat: open subtitles

This commit is contained in:
Fredrik Burmester
2026-01-18 13:20:17 +01:00
parent ebb33854d7
commit 18bc45ea0a
10 changed files with 361 additions and 382 deletions

View File

@@ -822,6 +822,21 @@ export default function SettingsTV() {
}
disabled={isModalOpen}
/>
<TVSettingsStepper
label={t("home.settings.subtitles.subtitle_size")}
value={settings.subtitleSize / 100}
onDecrease={() => {
const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
onIncrease={() => {
const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
formatValue={(v) => `${v.toFixed(1)}x`}
disabled={isModalOpen}
/>
{/* MPV Subtitles Section */}
<SectionHeader title='MPV Subtitle Settings' />
<TVSettingsStepper

View File

@@ -6,7 +6,6 @@ import {
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import {
getMediaInfoApi,
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
@@ -935,47 +934,16 @@ export default function page() {
}, []);
// TV: Handle server-side subtitle download (needs media source refresh)
// After downloading via Jellyfin API, we need to refresh the media source
// to see the new subtitle in the track list.
const handleServerSubtitleDownloaded = useCallback(async () => {
if (!api || !user?.Id || !item?.Id || !stream) {
console.warn("Cannot refresh media source: missing required data");
return;
}
try {
// Re-fetch playback info to get updated MediaSources with new subtitle
const res = await getMediaInfoApi(api).getPlaybackInfo(
{ itemId: item.Id },
{
method: "POST",
data: {
userId: user.Id,
deviceProfile: generateDeviceProfile(),
mediaSourceId: stream.mediaSource?.Id,
},
},
);
const newMediaSource = res.data.MediaSources?.find(
(ms) => ms.Id === stream.mediaSource?.Id,
);
if (newMediaSource) {
// Update the stream state with refreshed media source (preserving URL and sessionId)
setStream((prev) =>
prev ? { ...prev, mediaSource: newMediaSource } : prev,
);
console.log(
"Media source refreshed - new subtitle count:",
newMediaSource.MediaStreams?.filter((s) => s.Type === "Subtitle")
.length,
);
}
} catch (error) {
console.error("Failed to refresh media source:", error);
}
}, [api, user?.Id, item?.Id, stream]);
// Note: After downloading via Jellyfin API, the subtitle appears in the track list
// but we need to re-fetch the media source to see it. For now, we just log a message.
// A full implementation would refetch getStreamUrl and update the stream state.
const handleServerSubtitleDownloaded = useCallback(() => {
console.log(
"Server-side subtitle downloaded - track list should be refreshed",
);
// TODO: Implement media source refresh to pick up new subtitle
// This would involve re-calling getStreamUrl and updating the stream state
}, []);
// TV: Navigate to next item
const goToNextItem = useCallback(() => {

View File

@@ -32,8 +32,8 @@ import { Badge } from "@/components/Badge";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { TVSubtitleSheet } from "@/components/common/TVSubtitleSheet";
import { GenreTags } from "@/components/GenreTags";
import { TVSubtitleSheet } from "@/components/video-player/controls/TVSubtitleSheet";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
@@ -1037,13 +1037,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined));
}, []);
// Refresh item data when server-side subtitle is downloaded
// Handle server-side subtitle download - invalidate queries to refresh tracks
const handleServerSubtitleDownloaded = useCallback(() => {
// Invalidate item queries to refresh media sources with new subtitle
if (item?.Id) {
queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
}
}, [queryClient, item?.Id]);
}, [item?.Id, queryClient]);
// Get display values for buttons
const selectedAudioLabel = useMemo(() => {
@@ -1115,11 +1114,23 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
// Determine which option button is the last one (for focus guide targeting)
// Subtitle is always shown now (always has search capability)
const lastOptionButton = useMemo(() => {
// Subtitle is always the last button since it's always shown
return "subtitle";
}, []);
const hasSubtitleOption =
subtitleTracks.length > 0 ||
selectedOptions?.subtitleIndex !== undefined;
const hasAudioOption = audioTracks.length > 0;
const hasMediaSourceOption = mediaSources.length > 1;
if (hasSubtitleOption) return "subtitle";
if (hasAudioOption) return "audio";
if (hasMediaSourceOption) return "mediaSource";
return "quality";
}, [
subtitleTracks.length,
selectedOptions?.subtitleIndex,
audioTracks.length,
mediaSources.length,
]);
if (!item || !selectedOptions) return null;
@@ -1408,17 +1419,20 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
/>
)}
{/* Subtitle selector - always show to enable search */}
<TVOptionButton
ref={
lastOptionButton === "subtitle"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.subtitles.label")}
value={selectedSubtitleLabel}
onPress={() => setOpenModal("subtitle")}
/>
{/* Subtitle selector */}
{(subtitleTracks.length > 0 ||
selectedOptions?.subtitleIndex !== undefined) && (
<TVOptionButton
ref={
lastOptionButton === "subtitle"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.subtitles.label")}
value={selectedSubtitleLabel}
onPress={() => setOpenModal("subtitle")}
/>
)}
</View>
{/* Focus guide to direct navigation from options to cast list */}
@@ -1721,17 +1735,19 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
onClose={() => setOpenModal(null)}
/>
{/* Subtitle Sheet with tabs for tracks and search */}
<TVSubtitleSheet
visible={openModal === "subtitle"}
item={item}
mediaSourceId={selectedOptions?.mediaSource?.Id}
subtitleTracks={subtitleTracks}
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
onSubtitleChange={handleSubtitleChange}
onClose={() => setOpenModal(null)}
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
/>
{/* Unified Subtitle Sheet (tracks + download) */}
{item && (
<TVSubtitleSheet
visible={openModal === "subtitle"}
item={item}
mediaSourceId={selectedOptions?.mediaSource?.Id}
subtitleTracks={subtitleTracks}
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
onSubtitleIndexChange={handleSubtitleChange}
onClose={() => setOpenModal(null)}
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
/>
)}
</View>
);
},

View File

@@ -1,5 +1,12 @@
import { useEffect, useRef } from "react";
import { Platform } from "react-native";
import TrackPlayer, {
Event,
type PlaybackActiveTrackChangedEvent,
State,
useActiveTrack,
usePlaybackState,
useProgress,
} from "react-native-track-player";
import {
audioStorageEvents,
deleteTrack,
@@ -7,34 +14,7 @@ import {
} from "@/providers/AudioStorage";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
// TrackPlayer is not available on tvOS - wrap in try-catch in case native module isn't linked
let TrackPlayerModule: typeof import("react-native-track-player") | null = null;
if (!Platform.isTV) {
try {
TrackPlayerModule = require("react-native-track-player");
} catch (e) {
console.warn("TrackPlayer not available:", e);
}
}
type PlaybackActiveTrackChangedEvent = {
track?: { id?: string; url?: string };
lastTrack?: { id?: string };
};
type Track = { id?: string; url?: string; [key: string]: unknown };
type PlaybackErrorEvent = { code?: string; message?: string };
// Stub component for tvOS or when TrackPlayer is not available
const StubMusicPlaybackEngine: React.FC = () => null;
// Full implementation for non-TV platforms when TrackPlayer is available
const MobileMusicPlaybackEngine: React.FC = () => {
// These are guaranteed to exist since we only use this component when TrackPlayerModule is available
const TrackPlayer = TrackPlayerModule!.default;
const { Event, State, useActiveTrack, usePlaybackState, useProgress } =
TrackPlayerModule!;
export const MusicPlaybackEngine: React.FC = () => {
const { position, duration } = useProgress(1000);
const playbackState = usePlaybackState();
const activeTrack = useActiveTrack();
@@ -68,7 +48,7 @@ const MobileMusicPlaybackEngine: React.FC = () => {
useEffect(() => {
const isPlaying = playbackState.state === State.Playing;
setIsPlaying(isPlaying);
}, [playbackState.state, setIsPlaying, State.Playing]);
}, [playbackState.state, setIsPlaying]);
// Sync active track changes
useEffect(() => {
@@ -91,63 +71,59 @@ const MobileMusicPlaybackEngine: React.FC = () => {
// Listen for track changes (native -> JS)
// This triggers look-ahead caching, checks for cached versions, and handles track end
useEffect(() => {
const subscription = TrackPlayer.addEventListener(
Event.PlaybackActiveTrackChanged,
async (event: PlaybackActiveTrackChangedEvent) => {
// Trigger look-ahead caching when a new track starts playing
if (event.track) {
triggerLookahead();
const subscription =
TrackPlayer.addEventListener<PlaybackActiveTrackChangedEvent>(
Event.PlaybackActiveTrackChanged,
async (event) => {
// Trigger look-ahead caching when a new track starts playing
if (event.track) {
triggerLookahead();
// Check if there's a cached version we should use instead
const trackId = event.track.id;
const currentUrl = event.track.url as string;
// Check if there's a cached version we should use instead
const trackId = event.track.id;
const currentUrl = event.track.url as string;
// Only check if currently using a remote URL
if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
const cachedPath = getLocalPath(trackId);
if (cachedPath) {
console.log(
`[AudioCache] Switching to cached version for ${trackId}`,
);
try {
// Load the cached version, preserving position if any
const currentIndex = await TrackPlayer.getActiveTrackIndex();
if (currentIndex !== undefined && currentIndex >= 0) {
const queue = await TrackPlayer.getQueue();
const track = queue[currentIndex];
// Remove and re-add with cached URL
await TrackPlayer.remove(currentIndex);
await TrackPlayer.add(
{ ...track, url: cachedPath },
currentIndex,
);
await TrackPlayer.skip(currentIndex);
await TrackPlayer.play();
}
} catch (error) {
console.warn(
"[AudioCache] Failed to switch to cached version:",
error,
// Only check if currently using a remote URL
if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
const cachedPath = getLocalPath(trackId);
if (cachedPath) {
console.log(
`[AudioCache] Switching to cached version for ${trackId}`,
);
try {
// Load the cached version, preserving position if any
const currentIndex = await TrackPlayer.getActiveTrackIndex();
if (currentIndex !== undefined && currentIndex >= 0) {
const queue = await TrackPlayer.getQueue();
const track = queue[currentIndex];
// Remove and re-add with cached URL
await TrackPlayer.remove(currentIndex);
await TrackPlayer.add(
{ ...track, url: cachedPath },
currentIndex,
);
await TrackPlayer.skip(currentIndex);
await TrackPlayer.play();
}
} catch (error) {
console.warn(
"[AudioCache] Failed to switch to cached version:",
error,
);
}
}
}
}
}
// If there's no next track and the previous track ended, call onTrackEnd
if (event.lastTrack && !event.track) {
onTrackEnd();
}
},
);
// If there's no next track and the previous track ended, call onTrackEnd
if (event.lastTrack && !event.track) {
onTrackEnd();
}
},
);
return () => subscription.remove();
}, [
Event.PlaybackActiveTrackChanged,
TrackPlayer,
onTrackEnd,
triggerLookahead,
]);
}, [onTrackEnd, triggerLookahead]);
// Listen for audio cache download completion and update queue URLs
useEffect(() => {
@@ -165,7 +141,7 @@ const MobileMusicPlaybackEngine: React.FC = () => {
const currentIndex = await TrackPlayer.getActiveTrackIndex();
// Find the track in the queue
const trackIndex = queue.findIndex((t: Track) => t.id === itemId);
const trackIndex = queue.findIndex((t) => t.id === itemId);
// Only update if track is in queue and not currently playing
if (trackIndex >= 0 && trackIndex !== currentIndex) {
@@ -194,13 +170,13 @@ const MobileMusicPlaybackEngine: React.FC = () => {
return () => {
audioStorageEvents.off("complete", onComplete);
};
}, [TrackPlayer]);
}, []);
// Listen for playback errors (corrupted cache files)
useEffect(() => {
const subscription = TrackPlayer.addEventListener(
Event.PlaybackError,
async (event: PlaybackErrorEvent) => {
async (event) => {
const activeTrack = await TrackPlayer.getActiveTrack();
if (!activeTrack?.url) return;
@@ -239,14 +215,8 @@ const MobileMusicPlaybackEngine: React.FC = () => {
);
return () => subscription.remove();
}, [Event.PlaybackError, TrackPlayer]);
}, []);
// No visual component needed - TrackPlayer is headless
return null;
};
// Export the appropriate component based on platform and module availability
export const MusicPlaybackEngine: React.FC =
Platform.isTV || !TrackPlayerModule
? StubMusicPlaybackEngine
: MobileMusicPlaybackEngine;

View File

@@ -40,7 +40,6 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVSubtitleSheet } from "@/components/common/TVSubtitleSheet";
import useRouter from "@/hooks/useAppRouter";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
@@ -53,6 +52,7 @@ import { CONTROLS_CONSTANTS } from "./constants";
import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoTime } from "./hooks/useVideoTime";
import { TrickplayBubble } from "./TrickplayBubble";
import { TVSubtitleSheet } from "./TVSubtitleSheet";
import { useControlsTimeout } from "./useControlsTimeout";
interface Props {
@@ -916,6 +916,14 @@ export const Controls: FC<Props> = ({
return track?.DisplayTitle || track?.Language || t("item_card.audio");
}, [audioTracks, audioIndex, t]);
const _selectedSubtitleLabel = useMemo(() => {
if (subtitleIndex === -1) return t("item_card.subtitles.none");
const track = subtitleTracks.find((t) => t.Index === subtitleIndex);
return (
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
);
}, [subtitleTracks, subtitleIndex, t]);
// Handlers for option changes
const handleAudioChange = useCallback(
(index: number) => {
@@ -1050,6 +1058,19 @@ export const Controls: FC<Props> = ({
controlsInteractionRef.current();
}, []);
// Handler for when a subtitle is downloaded via server
const handleServerSubtitleDownloaded = useCallback(() => {
onServerSubtitleDownloaded?.();
}, [onServerSubtitleDownloaded]);
// Handler for when a subtitle is downloaded locally
const handleLocalSubtitleDownloaded = useCallback(
(path: string) => {
addSubtitleFile?.(path);
},
[addSubtitleFile],
);
// Progress value for the progress bar (directly from playback progress)
const effectiveProgress = useSharedValue(0);
@@ -1411,7 +1432,7 @@ export const Controls: FC<Props> = ({
/>
)}
{/* Subtitle button - always show to allow search even if no tracks */}
{/* Subtitle button */}
<TVControlButton
icon='text'
onPress={handleOpenSubtitleSheet}
@@ -1484,17 +1505,17 @@ export const Controls: FC<Props> = ({
onClose={() => setOpenModal(null)}
/>
{/* Subtitle Sheet with tabs for tracks and search */}
{/* Unified Subtitle Sheet (tracks + download) */}
<TVSubtitleSheet
visible={openModal === "subtitle"}
item={item}
mediaSourceId={mediaSource?.Id}
subtitleTracks={subtitleTracks}
currentSubtitleIndex={subtitleIndex ?? -1}
onSubtitleChange={handleSubtitleChange}
onSubtitleIndexChange={handleSubtitleChange}
onClose={() => setOpenModal(null)}
onServerSubtitleDownloaded={onServerSubtitleDownloaded}
onLocalSubtitleDownloaded={addSubtitleFile}
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded}
/>
</View>
);

View File

@@ -29,29 +29,34 @@ import {
} from "@/hooks/useRemoteSubtitles";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
interface Props {
interface TVSubtitleSheetProps {
visible: boolean;
item: BaseItemDto;
mediaSourceId?: string | null;
// Existing subtitle tracks from media source
subtitleTracks: MediaStream[];
currentSubtitleIndex: number;
onSubtitleChange: (index: number) => void;
// Track selection callback
onSubtitleIndexChange: (index: number) => void;
onClose: () => void;
/** Called when a subtitle is downloaded locally (client-side) - only during playback */
onLocalSubtitleDownloaded?: (path: string) => void;
/** Called when a subtitle is downloaded via Jellyfin API (server-side) */
// Optional - for during-playback context only
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
}
type TabType = "tracks" | "search";
type TabType = "tracks" | "download";
// Tab button component
const TVSubtitleTab: React.FC<{
// Tab button component - requires press to switch
const TVTabButton: React.FC<{
label: string;
isActive: boolean;
active: boolean;
onSelect: () => void;
hasTVPreferredFocus?: boolean;
}> = ({ label, isActive, onSelect, hasTVPreferredFocus }) => {
disabled?: boolean;
}> = ({ label, active, onSelect, hasTVPreferredFocus, disabled }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new RNAnimated.Value(1)).current;
@@ -65,16 +70,18 @@ const TVSubtitleTab: React.FC<{
return (
<Pressable
onPress={onSelect}
onFocus={() => {
setFocused(true);
animateTo(1.05);
onSelect();
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<RNAnimated.View
style={[
@@ -83,10 +90,10 @@ const TVSubtitleTab: React.FC<{
transform: [{ scale }],
backgroundColor: focused
? "#fff"
: isActive
: active
? "rgba(255,255,255,0.2)"
: "transparent",
borderBottomColor: isActive ? "#fff" : "transparent",
borderBottomColor: active ? "#fff" : "transparent",
},
]}
>
@@ -94,7 +101,7 @@ const TVSubtitleTab: React.FC<{
style={[
styles.tabText,
{ color: focused ? "#000" : "#fff" },
(focused || isActive) && { fontWeight: "600" },
(focused || active) && { fontWeight: "600" },
]}
>
{label}
@@ -104,16 +111,17 @@ const TVSubtitleTab: React.FC<{
);
};
// Track option card
// Track card for subtitle track selection
const TVTrackCard = React.forwardRef<
View,
{
label: string;
sublabel?: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
>(({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new RNAnimated.Value(1)).current;
@@ -162,6 +170,17 @@ const TVTrackCard = React.forwardRef<
>
{label}
</Text>
{sublabel && (
<Text
style={[
styles.trackCardSublabel,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
numberOfLines={1}
>
{sublabel}
</Text>
)}
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
@@ -305,7 +324,7 @@ const SubtitleResultCard = React.forwardRef<
},
]}
>
{/* Provider badge */}
{/* Provider/Source badge */}
<View
style={[
styles.providerBadge,
@@ -336,6 +355,7 @@ const SubtitleResultCard = React.forwardRef<
{/* Meta info row */}
<View style={styles.resultMeta}>
{/* Format */}
<Text
style={[
styles.resultMetaText,
@@ -345,6 +365,7 @@ const SubtitleResultCard = React.forwardRef<
{result.format?.toUpperCase()}
</Text>
{/* Rating if available */}
{result.communityRating !== undefined &&
result.communityRating > 0 && (
<View style={styles.ratingContainer}>
@@ -368,6 +389,7 @@ const SubtitleResultCard = React.forwardRef<
</View>
)}
{/* Download count if available */}
{result.downloadCount !== undefined && result.downloadCount > 0 && (
<View style={styles.downloadCountContainer}>
<Ionicons
@@ -441,6 +463,7 @@ const SubtitleResultCard = React.forwardRef<
)}
</View>
{/* Loading indicator when downloading */}
{isDownloading && (
<View style={styles.downloadingOverlay}>
<ActivityIndicator size='small' color='#fff' />
@@ -451,23 +474,25 @@ const SubtitleResultCard = React.forwardRef<
);
});
export const TVSubtitleSheet: React.FC<Props> = ({
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
visible,
item,
mediaSourceId,
subtitleTracks,
currentSubtitleIndex,
onSubtitleChange,
onSubtitleIndexChange,
onClose,
onLocalSubtitleDownloaded,
onServerSubtitleDownloaded,
onLocalSubtitleDownloaded,
}) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [isReady, setIsReady] = useState(false);
const [selectedLanguage, setSelectedLanguage] = useState("eng");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const firstTrackCardRef = useRef<View>(null);
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isTabContentReady, setIsTabContentReady] = useState(false);
const firstTrackRef = useRef<View>(null);
const {
hasOpenSubtitlesApiKey,
@@ -483,44 +508,28 @@ export const TVSubtitleSheet: React.FC<Props> = ({
mediaSourceId,
});
// Store reset in a ref to avoid dependency issues
const resetRef = useRef(reset);
resetRef.current = reset;
// Animation values
const overlayOpacity = useRef(new RNAnimated.Value(0)).current;
const sheetTranslateY = useRef(new RNAnimated.Value(300)).current;
// Build subtitle options (with "None" option)
const subtitleOptions = useMemo(() => {
const noneOption = {
label: t("item_card.subtitles.none"),
value: -1,
selected: currentSubtitleIndex === -1,
};
const trackOptions = subtitleTracks.map((track) => ({
label:
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
value: track.Index!,
selected: track.Index === currentSubtitleIndex,
}));
return [noneOption, ...trackOptions];
}, [subtitleTracks, currentSubtitleIndex, t]);
// Find initial selected index for focus
const initialSelectedIndex = useMemo(() => {
const idx = subtitleOptions.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [subtitleOptions]);
// Languages for search
const displayLanguages = useMemo(
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
[],
);
// Determine initial selected track index
const initialSelectedTrackIndex = useMemo(() => {
if (currentSubtitleIndex === -1) return 0; // "None" option
const trackIdx = subtitleTracks.findIndex(
(t) => t.Index === currentSubtitleIndex,
);
return trackIdx >= 0 ? trackIdx + 1 : 0; // +1 because "None" is at index 0
}, [subtitleTracks, currentSubtitleIndex]);
// Animate in/out
useEffect(() => {
if (visible) {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(300);
setActiveTab("tracks");
RNAnimated.parallel([
RNAnimated.timing(overlayOpacity, {
@@ -536,10 +545,18 @@ export const TVSubtitleSheet: React.FC<Props> = ({
useNativeDriver: true,
}),
]).start();
} else {
reset();
}
}, [visible, overlayOpacity, sheetTranslateY, reset]);
}, [visible, overlayOpacity, sheetTranslateY]);
// Reset state when sheet closes
useEffect(() => {
if (!visible) {
setHasSearchedThisSession(false);
setActiveTab("tracks");
resetRef.current();
setIsReady(false);
}
}, [visible]);
// Delay rendering to work around hasTVPreferredFocus timing issue
useEffect(() => {
@@ -550,22 +567,23 @@ export const TVSubtitleSheet: React.FC<Props> = ({
setIsReady(false);
}, [visible]);
// Programmatic focus fallback
// Lazy loading: search when Download tab is first activated
useEffect(() => {
if (isReady && firstTrackCardRef.current) {
const timer = setTimeout(() => {
(firstTrackCardRef.current as any)?.requestTVFocus?.();
}, 50);
if (visible && activeTab === "download" && !hasSearchedThisSession) {
search({ language: selectedLanguage });
setHasSearchedThisSession(true);
}
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
// Delay tab content rendering to prevent focus conflicts when switching tabs
useEffect(() => {
if (isReady) {
setIsTabContentReady(false);
const timer = setTimeout(() => setIsTabContentReady(true), 50);
return () => clearTimeout(timer);
}
}, [isReady]);
// Auto-search when switching to search tab
useEffect(() => {
if (activeTab === "search" && !searchResults && !isSearching) {
search({ language: selectedLanguage });
}
}, [activeTab, searchResults, isSearching, search, selectedLanguage]);
setIsTabContentReady(false);
}, [activeTab, isReady]);
// Handle language selection
const handleLanguageSelect = useCallback(
@@ -579,10 +597,10 @@ export const TVSubtitleSheet: React.FC<Props> = ({
// Handle track selection
const handleTrackSelect = useCallback(
(index: number) => {
onSubtitleChange(index);
onSubtitleIndexChange(index);
onClose();
},
[onSubtitleChange, onClose],
[onSubtitleIndexChange, onClose],
);
// Handle subtitle download
@@ -614,8 +632,29 @@ export const TVSubtitleSheet: React.FC<Props> = ({
],
);
// Whether we're in player context (can use local subtitles)
const isInPlayer = Boolean(onLocalSubtitleDownloaded);
// Subset of common languages for TV
const displayLanguages = useMemo(
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
[],
);
// Track options with "None" at the start
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;
@@ -629,53 +668,54 @@ export const TVSubtitleSheet: React.FC<Props> = ({
>
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header with title */}
<Text style={styles.title}>
{t("item_card.subtitles.label").toUpperCase()}
</Text>
{/* Header with tabs */}
<View style={styles.header}>
<Text style={styles.title}>
{t("item_card.subtitles.label") || "Subtitles"}
</Text>
{/* Tabs */}
<View style={styles.tabRow}>
<TVSubtitleTab
label={t("player.subtitle_tracks") || "Tracks"}
isActive={activeTab === "tracks"}
onSelect={() => setActiveTab("tracks")}
hasTVPreferredFocus={true}
/>
<TVSubtitleTab
label={t("player.subtitle_search") || "Search & Download"}
isActive={activeTab === "search"}
onSelect={() => setActiveTab("search")}
/>
{/* 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>
{/* Tab Content */}
{activeTab === "tracks" && isReady && (
<View style={styles.tabContent}>
{/* Tracks Tab Content */}
{activeTab === "tracks" && isTabContentReady && (
<View style={styles.section}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
style={styles.tracksScroll}
contentContainerStyle={styles.tracksScrollContent}
>
{subtitleOptions.map((option, index) => (
{trackOptions.map((option, index) => (
<TVTrackCard
key={index}
key={option.value}
ref={
index === initialSelectedIndex
? firstTrackCardRef
index === initialSelectedTrackIndex
? firstTrackRef
: undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
hasTVPreferredFocus={index === initialSelectedTrackIndex}
onPress={() => handleTrackSelect(option.value)}
/>
))}
@@ -683,23 +723,9 @@ export const TVSubtitleSheet: React.FC<Props> = ({
</View>
)}
{activeTab === "search" && (
<View style={styles.tabContent}>
{/* Download hint - only show on item details page */}
{!isInPlayer && (
<View style={styles.downloadHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.5)'
/>
<Text style={styles.downloadHintText}>
{t("player.subtitle_download_hint") ||
"Downloaded subtitles will be saved to your library"}
</Text>
</View>
)}
{/* Download Tab Content */}
{activeTab === "download" && isTabContentReady && (
<>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
@@ -708,7 +734,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
style={styles.languageScroll}
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
@@ -789,7 +815,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
style={styles.resultsScroll}
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
@@ -805,7 +831,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
)}
</View>
{/* API Key hint */}
{/* API Key hint if no fallback available */}
{!hasOpenSubtitlesApiKey && (
<View style={styles.apiKeyHint}>
<Ionicons
@@ -819,7 +845,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
</Text>
</View>
)}
</View>
</>
)}
</TVFocusGuideView>
</BlurView>
@@ -840,7 +866,7 @@ const styles = StyleSheet.create({
zIndex: 1000,
},
sheetContainer: {
maxHeight: "75%",
maxHeight: "70%",
},
blurContainer: {
borderTopLeftRadius: 24,
@@ -850,21 +876,19 @@ const styles = StyleSheet.create({
content: {
paddingTop: 24,
paddingBottom: 48,
overflow: "visible",
},
header: {
paddingHorizontal: 48,
marginBottom: 20,
},
title: {
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
fontSize: 24,
fontWeight: "600",
color: "#fff",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
tabRow: {
flexDirection: "row",
paddingHorizontal: 48,
marginBottom: 16,
gap: 24,
},
tabButton: {
@@ -876,15 +900,24 @@ const styles = StyleSheet.create({
tabText: {
fontSize: 18,
},
tabContent: {
overflow: "visible",
section: {
marginBottom: 20,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
sectionTitle: {
fontSize: 14,
fontWeight: "500",
color: "rgba(255,255,255,0.5)",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 12,
paddingHorizontal: 48,
paddingVertical: 10,
},
tracksScroll: {
overflow: "visible",
},
tracksScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
},
trackCard: {
@@ -899,33 +932,17 @@ const styles = StyleSheet.create({
fontSize: 16,
textAlign: "center",
},
trackCardSublabel: {
fontSize: 12,
marginTop: 2,
},
checkmark: {
position: "absolute",
top: 8,
right: 8,
},
downloadHint: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 48,
marginBottom: 16,
},
downloadHintText: {
color: "rgba(255,255,255,0.5)",
fontSize: 14,
},
section: {
marginBottom: 16,
},
sectionTitle: {
fontSize: 14,
fontWeight: "500",
color: "rgba(255,255,255,0.5)",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 12,
paddingHorizontal: 48,
languageScroll: {
overflow: "visible",
},
languageScrollContent: {
paddingHorizontal: 48,
@@ -948,6 +965,9 @@ const styles = StyleSheet.create({
fontSize: 11,
marginTop: 2,
},
resultsScroll: {
overflow: "visible",
},
resultsScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,

View File

@@ -2,13 +2,9 @@ import "react-native-url-polyfill/auto";
import { Platform } from "react-native";
import "expo-router/entry";
// TrackPlayer is not supported on tvOS - wrap in try-catch in case native module isn't linked
// TrackPlayer is not supported on tvOS
if (!Platform.isTV) {
try {
const TrackPlayer = require("react-native-track-player").default;
const { PlaybackService } = require("./services/PlaybackService");
TrackPlayer.registerPlaybackService(() => PlaybackService);
} catch (e) {
console.warn("TrackPlayer not available:", e);
}
const TrackPlayer = require("react-native-track-player").default;
const { PlaybackService } = require("./services/PlaybackService");
TrackPlayer.registerPlaybackService(() => PlaybackService);
}

View File

@@ -30,32 +30,20 @@ import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { storage } from "@/utils/mmkv";
// Conditionally import TrackPlayer only on non-TV platforms
// Wrap in try-catch in case native module isn't linked
let TrackPlayerModule: typeof import("react-native-track-player") | null = null;
if (!Platform.isTV) {
try {
TrackPlayerModule = require("react-native-track-player");
} catch (e) {
console.warn("TrackPlayer not available:", e);
}
}
const TrackPlayer = TrackPlayerModule?.default ?? null;
// This prevents the native module from being loaded on TV where it doesn't exist
const TrackPlayer = Platform.isTV
? null
: require("react-native-track-player").default;
const TrackPlayerModule = Platform.isTV
? null
: require("react-native-track-player");
// Extract types and enums from the module (only available on non-TV)
const Capability = TrackPlayerModule?.Capability;
const TPRepeatMode = TrackPlayerModule?.RepeatMode;
// Define types locally since they can't be extracted from conditional import
type Track = {
id: string;
url: string;
title?: string;
artist?: string;
album?: string;
artwork?: string;
duration?: number;
};
type Progress = { position: number; duration: number; buffered: number };
type Track = NonNullable<typeof TrackPlayerModule>["Track"];
type Progress = NonNullable<typeof TrackPlayerModule>["Progress"];
// Storage keys
const STORAGE_KEYS = {
@@ -394,7 +382,7 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
// Setup TrackPlayer and AudioStorage
useEffect(() => {
if (!TrackPlayer || !Capability) return;
if (!TrackPlayer) return;
const setupPlayer = async () => {
if (playerSetupRef.current) return;
@@ -444,21 +432,21 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
// Sync repeat mode to TrackPlayer
useEffect(() => {
if (!TrackPlayer || !TPRepeatMode) return;
if (!TrackPlayer) return;
const syncRepeatMode = async () => {
if (!playerSetupRef.current) return;
let tpRepeatMode: number;
let tpRepeatMode: typeof TPRepeatMode;
switch (state.repeatMode) {
case "one":
tpRepeatMode = TPRepeatMode.Track;
tpRepeatMode = TPRepeatMode?.Track;
break;
case "all":
tpRepeatMode = TPRepeatMode.Queue;
tpRepeatMode = TPRepeatMode?.Queue;
break;
default:
tpRepeatMode = TPRepeatMode.Off;
tpRepeatMode = TPRepeatMode?.Off;
}
await TrackPlayer.setRepeatMode(tpRepeatMode);
};

View File

@@ -1,22 +1,6 @@
import { Platform } from "react-native";
// TrackPlayer is not available on tvOS - wrap in try-catch in case native module isn't linked
let TrackPlayer: typeof import("react-native-track-player").default | null =
null;
let Event: typeof import("react-native-track-player").Event | null = null;
if (!Platform.isTV) {
try {
TrackPlayer = require("react-native-track-player").default;
Event = require("react-native-track-player").Event;
} catch (e) {
console.warn("TrackPlayer not available:", e);
}
}
import TrackPlayer, { Event } from "react-native-track-player";
export const PlaybackService = async () => {
// TrackPlayer is not supported on tvOS
if (Platform.isTV || !TrackPlayer || !Event) return;
TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
@@ -29,9 +13,8 @@ export const PlaybackService = async () => {
TrackPlayer.skipToPrevious(),
);
TrackPlayer.addEventListener(
Event.RemoteSeek,
(event: { position: number }) => TrackPlayer.seekTo(event.position),
TrackPlayer.addEventListener(Event.RemoteSeek, (event) =>
TrackPlayer.seekTo(event.position),
);
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.reset());

View File

@@ -628,6 +628,7 @@
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",
"download": "Download",
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
"using_jellyfin_server": "Using Jellyfin Server",
"language": "Language",
@@ -661,7 +662,8 @@
"audio": "Audio",
"subtitles": {
"label": "Subtitle",
"none": "None"
"none": "None",
"tracks": "Tracks"
},
"show_more": "Show More",
"show_less": "Show Less",