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} 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 */} {/* MPV Subtitles Section */}
<SectionHeader title='MPV Subtitle Settings' /> <SectionHeader title='MPV Subtitle Settings' />
<TVSettingsStepper <TVSettingsStepper

View File

@@ -6,7 +6,6 @@ import {
RepeatMode, RepeatMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { import {
getMediaInfoApi,
getPlaystateApi, getPlaystateApi,
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
@@ -935,47 +934,16 @@ export default function page() {
}, []); }, []);
// TV: Handle server-side subtitle download (needs media source refresh) // TV: Handle server-side subtitle download (needs media source refresh)
// After downloading via Jellyfin API, we need to refresh the media source // Note: After downloading via Jellyfin API, the subtitle appears in the track list
// to see the new subtitle in the track list. // but we need to re-fetch the media source to see it. For now, we just log a message.
const handleServerSubtitleDownloaded = useCallback(async () => { // A full implementation would refetch getStreamUrl and update the stream state.
if (!api || !user?.Id || !item?.Id || !stream) { const handleServerSubtitleDownloaded = useCallback(() => {
console.warn("Cannot refresh media source: missing required data"); console.log(
return; "Server-side subtitle downloaded - track list should be refreshed",
} );
// TODO: Implement media source refresh to pick up new subtitle
try { // This would involve re-calling getStreamUrl and updating the stream state
// 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]);
// TV: Navigate to next item // TV: Navigate to next item
const goToNextItem = useCallback(() => { const goToNextItem = useCallback(() => {

View File

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

View File

@@ -1,5 +1,12 @@
import { useEffect, useRef } from "react"; 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 { import {
audioStorageEvents, audioStorageEvents,
deleteTrack, deleteTrack,
@@ -7,34 +14,7 @@ import {
} from "@/providers/AudioStorage"; } from "@/providers/AudioStorage";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
// TrackPlayer is not available on tvOS - wrap in try-catch in case native module isn't linked export const MusicPlaybackEngine: React.FC = () => {
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!;
const { position, duration } = useProgress(1000); const { position, duration } = useProgress(1000);
const playbackState = usePlaybackState(); const playbackState = usePlaybackState();
const activeTrack = useActiveTrack(); const activeTrack = useActiveTrack();
@@ -68,7 +48,7 @@ const MobileMusicPlaybackEngine: React.FC = () => {
useEffect(() => { useEffect(() => {
const isPlaying = playbackState.state === State.Playing; const isPlaying = playbackState.state === State.Playing;
setIsPlaying(isPlaying); setIsPlaying(isPlaying);
}, [playbackState.state, setIsPlaying, State.Playing]); }, [playbackState.state, setIsPlaying]);
// Sync active track changes // Sync active track changes
useEffect(() => { useEffect(() => {
@@ -91,63 +71,59 @@ const MobileMusicPlaybackEngine: React.FC = () => {
// Listen for track changes (native -> JS) // Listen for track changes (native -> JS)
// This triggers look-ahead caching, checks for cached versions, and handles track end // This triggers look-ahead caching, checks for cached versions, and handles track end
useEffect(() => { useEffect(() => {
const subscription = TrackPlayer.addEventListener( const subscription =
Event.PlaybackActiveTrackChanged, TrackPlayer.addEventListener<PlaybackActiveTrackChangedEvent>(
async (event: PlaybackActiveTrackChangedEvent) => { Event.PlaybackActiveTrackChanged,
// Trigger look-ahead caching when a new track starts playing async (event) => {
if (event.track) { // Trigger look-ahead caching when a new track starts playing
triggerLookahead(); if (event.track) {
triggerLookahead();
// Check if there's a cached version we should use instead // Check if there's a cached version we should use instead
const trackId = event.track.id; const trackId = event.track.id;
const currentUrl = event.track.url as string; const currentUrl = event.track.url as string;
// Only check if currently using a remote URL // Only check if currently using a remote URL
if (trackId && currentUrl && !currentUrl.startsWith("file://")) { if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
const cachedPath = getLocalPath(trackId); const cachedPath = getLocalPath(trackId);
if (cachedPath) { if (cachedPath) {
console.log( console.log(
`[AudioCache] Switching to cached version for ${trackId}`, `[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,
); );
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 there's no next track and the previous track ended, call onTrackEnd
if (event.lastTrack && !event.track) { if (event.lastTrack && !event.track) {
onTrackEnd(); onTrackEnd();
} }
}, },
); );
return () => subscription.remove(); return () => subscription.remove();
}, [ }, [onTrackEnd, triggerLookahead]);
Event.PlaybackActiveTrackChanged,
TrackPlayer,
onTrackEnd,
triggerLookahead,
]);
// Listen for audio cache download completion and update queue URLs // Listen for audio cache download completion and update queue URLs
useEffect(() => { useEffect(() => {
@@ -165,7 +141,7 @@ const MobileMusicPlaybackEngine: React.FC = () => {
const currentIndex = await TrackPlayer.getActiveTrackIndex(); const currentIndex = await TrackPlayer.getActiveTrackIndex();
// Find the track in the queue // 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 // Only update if track is in queue and not currently playing
if (trackIndex >= 0 && trackIndex !== currentIndex) { if (trackIndex >= 0 && trackIndex !== currentIndex) {
@@ -194,13 +170,13 @@ const MobileMusicPlaybackEngine: React.FC = () => {
return () => { return () => {
audioStorageEvents.off("complete", onComplete); audioStorageEvents.off("complete", onComplete);
}; };
}, [TrackPlayer]); }, []);
// Listen for playback errors (corrupted cache files) // Listen for playback errors (corrupted cache files)
useEffect(() => { useEffect(() => {
const subscription = TrackPlayer.addEventListener( const subscription = TrackPlayer.addEventListener(
Event.PlaybackError, Event.PlaybackError,
async (event: PlaybackErrorEvent) => { async (event) => {
const activeTrack = await TrackPlayer.getActiveTrack(); const activeTrack = await TrackPlayer.getActiveTrack();
if (!activeTrack?.url) return; if (!activeTrack?.url) return;
@@ -239,14 +215,8 @@ const MobileMusicPlaybackEngine: React.FC = () => {
); );
return () => subscription.remove(); return () => subscription.remove();
}, [Event.PlaybackError, TrackPlayer]); }, []);
// No visual component needed - TrackPlayer is headless // No visual component needed - TrackPlayer is headless
return null; 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"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVSubtitleSheet } from "@/components/common/TVSubtitleSheet";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
@@ -53,6 +52,7 @@ import { CONTROLS_CONSTANTS } from "./constants";
import { useRemoteControl } from "./hooks/useRemoteControl"; import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoTime } from "./hooks/useVideoTime"; import { useVideoTime } from "./hooks/useVideoTime";
import { TrickplayBubble } from "./TrickplayBubble"; import { TrickplayBubble } from "./TrickplayBubble";
import { TVSubtitleSheet } from "./TVSubtitleSheet";
import { useControlsTimeout } from "./useControlsTimeout"; import { useControlsTimeout } from "./useControlsTimeout";
interface Props { interface Props {
@@ -916,6 +916,14 @@ export const Controls: FC<Props> = ({
return track?.DisplayTitle || track?.Language || t("item_card.audio"); return track?.DisplayTitle || track?.Language || t("item_card.audio");
}, [audioTracks, audioIndex, t]); }, [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 // Handlers for option changes
const handleAudioChange = useCallback( const handleAudioChange = useCallback(
(index: number) => { (index: number) => {
@@ -1050,6 +1058,19 @@ export const Controls: FC<Props> = ({
controlsInteractionRef.current(); 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) // Progress value for the progress bar (directly from playback progress)
const effectiveProgress = useSharedValue(0); 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 <TVControlButton
icon='text' icon='text'
onPress={handleOpenSubtitleSheet} onPress={handleOpenSubtitleSheet}
@@ -1484,17 +1505,17 @@ export const Controls: FC<Props> = ({
onClose={() => setOpenModal(null)} onClose={() => setOpenModal(null)}
/> />
{/* Subtitle Sheet with tabs for tracks and search */} {/* Unified Subtitle Sheet (tracks + download) */}
<TVSubtitleSheet <TVSubtitleSheet
visible={openModal === "subtitle"} visible={openModal === "subtitle"}
item={item} item={item}
mediaSourceId={mediaSource?.Id} mediaSourceId={mediaSource?.Id}
subtitleTracks={subtitleTracks} subtitleTracks={subtitleTracks}
currentSubtitleIndex={subtitleIndex ?? -1} currentSubtitleIndex={subtitleIndex ?? -1}
onSubtitleChange={handleSubtitleChange} onSubtitleIndexChange={handleSubtitleChange}
onClose={() => setOpenModal(null)} onClose={() => setOpenModal(null)}
onServerSubtitleDownloaded={onServerSubtitleDownloaded} onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
onLocalSubtitleDownloaded={addSubtitleFile} onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded}
/> />
</View> </View>
); );

View File

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

View File

@@ -2,13 +2,9 @@ import "react-native-url-polyfill/auto";
import { Platform } from "react-native"; import { Platform } from "react-native";
import "expo-router/entry"; 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) { if (!Platform.isTV) {
try { const TrackPlayer = require("react-native-track-player").default;
const TrackPlayer = require("react-native-track-player").default; const { PlaybackService } = require("./services/PlaybackService");
const { PlaybackService } = require("./services/PlaybackService"); TrackPlayer.registerPlaybackService(() => PlaybackService);
TrackPlayer.registerPlaybackService(() => PlaybackService);
} catch (e) {
console.warn("TrackPlayer not available:", e);
}
} }

View File

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

View File

@@ -1,22 +1,6 @@
import { Platform } from "react-native"; import TrackPlayer, { Event } from "react-native-track-player";
// 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);
}
}
export const PlaybackService = async () => { 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.RemotePlay, () => TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause()); TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
@@ -29,9 +13,8 @@ export const PlaybackService = async () => {
TrackPlayer.skipToPrevious(), TrackPlayer.skipToPrevious(),
); );
TrackPlayer.addEventListener( TrackPlayer.addEventListener(Event.RemoteSeek, (event) =>
Event.RemoteSeek, TrackPlayer.seekTo(event.position),
(event: { position: number }) => TrackPlayer.seekTo(event.position),
); );
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.reset()); TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.reset());

View File

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