diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 20a6031e..46081e1f 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -822,6 +822,21 @@ export default function SettingsTV() { } disabled={isModalOpen} /> + { + 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 */} { - 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(() => { diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 01ee1392..8286b595 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -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 = 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 = 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 = React.memo( /> )} - {/* Subtitle selector - always show to enable search */} - setOpenModal("subtitle")} - /> + {/* Subtitle selector */} + {(subtitleTracks.length > 0 || + selectedOptions?.subtitleIndex !== undefined) && ( + setOpenModal("subtitle")} + /> + )} {/* Focus guide to direct navigation from options to cast list */} @@ -1721,17 +1735,19 @@ export const ItemContentTV: React.FC = React.memo( onClose={() => setOpenModal(null)} /> - {/* Subtitle Sheet with tabs for tracks and search */} - setOpenModal(null)} - onServerSubtitleDownloaded={handleServerSubtitleDownloaded} - /> + {/* Unified Subtitle Sheet (tracks + download) */} + {item && ( + setOpenModal(null)} + onServerSubtitleDownloaded={handleServerSubtitleDownloaded} + /> + )} ); }, diff --git a/components/music/MusicPlaybackEngine.tsx b/components/music/MusicPlaybackEngine.tsx index 5fa3dc7e..ae1b07cd 100644 --- a/components/music/MusicPlaybackEngine.tsx +++ b/components/music/MusicPlaybackEngine.tsx @@ -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( + 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; diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 04b321f2..b421d7db 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -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 = ({ 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 = ({ 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 = ({ /> )} - {/* Subtitle button - always show to allow search even if no tracks */} + {/* Subtitle button */} = ({ onClose={() => setOpenModal(null)} /> - {/* Subtitle Sheet with tabs for tracks and search */} + {/* Unified Subtitle Sheet (tracks + download) */} setOpenModal(null)} - onServerSubtitleDownloaded={onServerSubtitleDownloaded} - onLocalSubtitleDownloaded={addSubtitleFile} + onServerSubtitleDownloaded={handleServerSubtitleDownloaded} + onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded} /> ); diff --git a/components/common/TVSubtitleSheet.tsx b/components/video-player/controls/TVSubtitleSheet.tsx similarity index 82% rename from components/common/TVSubtitleSheet.tsx rename to components/video-player/controls/TVSubtitleSheet.tsx index c9fce84a..8d0d22a9 100644 --- a/components/common/TVSubtitleSheet.tsx +++ b/components/video-player/controls/TVSubtitleSheet.tsx @@ -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 ( { setFocused(true); animateTo(1.05); - onSelect(); }} onBlur={() => { setFocused(false); animateTo(1); }} - hasTVPreferredFocus={hasTVPreferredFocus} + hasTVPreferredFocus={hasTVPreferredFocus && !disabled} + disabled={disabled} + focusable={!disabled} > @@ -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} + {sublabel && ( + + {sublabel} + + )} {selected && !focused && ( - {/* Provider badge */} + {/* Provider/Source badge */} + {/* Format */} + {/* Rating if available */} {result.communityRating !== undefined && result.communityRating > 0 && ( @@ -368,6 +389,7 @@ const SubtitleResultCard = React.forwardRef< )} + {/* Download count if available */} {result.downloadCount !== undefined && result.downloadCount > 0 && ( + {/* Loading indicator when downloading */} {isDownloading && ( @@ -451,23 +474,25 @@ const SubtitleResultCard = React.forwardRef< ); }); -export const TVSubtitleSheet: React.FC = ({ +export const TVSubtitleSheet: React.FC = ({ visible, item, mediaSourceId, subtitleTracks, currentSubtitleIndex, - onSubtitleChange, + onSubtitleIndexChange, onClose, - onLocalSubtitleDownloaded, onServerSubtitleDownloaded, + onLocalSubtitleDownloaded, }) => { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("tracks"); - const [isReady, setIsReady] = useState(false); const [selectedLanguage, setSelectedLanguage] = useState("eng"); const [downloadingId, setDownloadingId] = useState(null); - const firstTrackCardRef = useRef(null); + const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false); + const [isReady, setIsReady] = useState(false); + const [isTabContentReady, setIsTabContentReady] = useState(false); + const firstTrackRef = useRef(null); const { hasOpenSubtitlesApiKey, @@ -483,44 +508,28 @@ export const TVSubtitleSheet: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ // 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 = ({ ], ); - // 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 = ({ > - {/* Header with title */} - - {t("item_card.subtitles.label").toUpperCase()} - + {/* Header with tabs */} + + + {t("item_card.subtitles.label") || "Subtitles"} + - {/* Tabs */} - - setActiveTab("tracks")} - hasTVPreferredFocus={true} - /> - setActiveTab("search")} - /> + {/* Tab bar */} + + setActiveTab("tracks")} + /> + setActiveTab("download")} + /> + - {/* Tab Content */} - {activeTab === "tracks" && isReady && ( - + {/* Tracks Tab Content */} + {activeTab === "tracks" && isTabContentReady && ( + - {subtitleOptions.map((option, index) => ( + {trackOptions.map((option, index) => ( handleTrackSelect(option.value)} /> ))} @@ -683,23 +723,9 @@ export const TVSubtitleSheet: React.FC = ({ )} - {activeTab === "search" && ( - - {/* Download hint - only show on item details page */} - {!isInPlayer && ( - - - - {t("player.subtitle_download_hint") || - "Downloaded subtitles will be saved to your library"} - - - )} - + {/* Download Tab Content */} + {activeTab === "download" && isTabContentReady && ( + <> {/* Language Selector */} @@ -708,7 +734,7 @@ export const TVSubtitleSheet: React.FC = ({ {displayLanguages.map((lang, index) => ( @@ -789,7 +815,7 @@ export const TVSubtitleSheet: React.FC = ({ {searchResults.map((result, index) => ( @@ -805,7 +831,7 @@ export const TVSubtitleSheet: React.FC = ({ )} - {/* API Key hint */} + {/* API Key hint if no fallback available */} {!hasOpenSubtitlesApiKey && ( = ({ )} - + )} @@ -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, diff --git a/index.js b/index.js index 94c4bfe2..7a0294a3 100644 --- a/index.js +++ b/index.js @@ -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); } diff --git a/providers/MusicPlayerProvider.tsx b/providers/MusicPlayerProvider.tsx index 13584b8c..71bce6e1 100644 --- a/providers/MusicPlayerProvider.tsx +++ b/providers/MusicPlayerProvider.tsx @@ -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["Track"]; +type Progress = NonNullable["Progress"]; // Storage keys const STORAGE_KEYS = { @@ -394,7 +382,7 @@ const MobileMusicPlayerProvider: React.FC = ({ // 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 = ({ // 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); }; diff --git a/services/PlaybackService.ts b/services/PlaybackService.ts index 35e95a1b..554d4476 100644 --- a/services/PlaybackService.ts +++ b/services/PlaybackService.ts @@ -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()); diff --git a/translations/en.json b/translations/en.json index 991dbf52..8e81cc30 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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",