From c515d037cfd8f1290a133c24f779d0bd010d79f9 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 18 Jan 2026 11:13:57 +0100 Subject: [PATCH] refactor(tv): unify subtitle track selector and search into tabbed sheet --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 15 - app/(auth)/player/direct-player.tsx | 52 +- components/ItemContent.tv.tsx | 85 +-- .../TVSubtitleSheet.tsx} | 623 +++++++++++++----- components/music/MusicPlaybackEngine.tsx | 144 ++-- .../video-player/controls/Controls.tv.tsx | 87 +-- index.js | 12 +- modules/mpv-player/ios/MPVLayerRenderer.swift | 61 +- modules/mpv-player/ios/MpvPlayerView.swift | 58 ++ providers/MusicPlayerProvider.tsx | 44 +- services/PlaybackService.ts | 23 +- translations/en.json | 21 +- 12 files changed, 826 insertions(+), 399 deletions(-) rename components/{video-player/controls/TVSubtitleSearch.tsx => common/TVSubtitleSheet.tsx} (52%) diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 46081e1f..20a6031e 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -822,21 +822,6 @@ 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 */} { - 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 - }, []); + // 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]); // TV: Navigate to next item const goToNextItem = useCallback(() => { diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index d3591ad5..01ee1392 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -3,6 +3,7 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; +import { useQueryClient } from "@tanstack/react-query"; import { BlurView } from "expo-blur"; import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; @@ -31,6 +32,7 @@ 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 useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; @@ -856,6 +858,7 @@ export const ItemContentTV: React.FC = React.memo( const insets = useSafeAreaInsets(); const router = useRouter(); const { t } = useTranslation(); + const queryClient = useQueryClient(); const _itemColors = useImageColorsReturn({ item }); @@ -969,23 +972,6 @@ export const ItemContentTV: React.FC = React.memo( })); }, [audioTracks, selectedOptions?.audioIndex]); - // Subtitle options for selector (with "None" option) - const subtitleOptions = useMemo(() => { - const noneOption = { - label: t("item_card.subtitles.none"), - value: -1, - selected: selectedOptions?.subtitleIndex === -1, - }; - const trackOptions = subtitleTracks.map((track) => ({ - label: - track.DisplayTitle || - `${track.Language || "Unknown"} (${track.Codec})`, - value: track.Index!, - selected: track.Index === selectedOptions?.subtitleIndex, - })); - return [noneOption, ...trackOptions]; - }, [subtitleTracks, selectedOptions?.subtitleIndex, t]); - // Media source options for selector const mediaSourceOptions = useMemo(() => { return mediaSources.map((source) => { @@ -1051,6 +1037,14 @@ export const ItemContentTV: React.FC = React.memo( setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined)); }, []); + // Refresh item data when server-side subtitle is downloaded + 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]); + // Get display values for buttons const selectedAudioLabel = useMemo(() => { const track = audioTracks.find( @@ -1121,23 +1115,11 @@ 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(() => { - 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, - ]); + // Subtitle is always the last button since it's always shown + return "subtitle"; + }, []); if (!item || !selectedOptions) return null; @@ -1426,20 +1408,17 @@ export const ItemContentTV: React.FC = React.memo( /> )} - {/* Subtitle selector */} - {(subtitleTracks.length > 0 || - selectedOptions?.subtitleIndex !== undefined) && ( - setOpenModal("subtitle")} - /> - )} + {/* Subtitle selector - always show to enable search */} + setOpenModal("subtitle")} + /> {/* Focus guide to direct navigation from options to cast list */} @@ -1742,12 +1721,16 @@ export const ItemContentTV: React.FC = React.memo( onClose={() => setOpenModal(null)} /> - setOpenModal(null)} + onServerSubtitleDownloaded={handleServerSubtitleDownloaded} /> ); diff --git a/components/video-player/controls/TVSubtitleSearch.tsx b/components/common/TVSubtitleSheet.tsx similarity index 52% rename from components/video-player/controls/TVSubtitleSearch.tsx rename to components/common/TVSubtitleSheet.tsx index 38914707..c9fce84a 100644 --- a/components/video-player/controls/TVSubtitleSearch.tsx +++ b/components/common/TVSubtitleSheet.tsx @@ -1,5 +1,8 @@ import { Ionicons } from "@expo/vector-icons"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import type { + BaseItemDto, + MediaStream, +} from "@jellyfin/sdk/lib/generated-client"; import { BlurView } from "expo-blur"; import React, { useCallback, @@ -30,13 +33,149 @@ interface Props { visible: boolean; item: BaseItemDto; mediaSourceId?: string | null; + subtitleTracks: MediaStream[]; + currentSubtitleIndex: number; + onSubtitleChange: (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) */ - onServerSubtitleDownloaded: () => void; - /** Called when a subtitle is downloaded locally (client-side) */ - onLocalSubtitleDownloaded: (path: string) => void; + onServerSubtitleDownloaded?: () => void; } +type TabType = "tracks" | "search"; + +// Tab button component +const TVSubtitleTab: React.FC<{ + label: string; + isActive: boolean; + onSelect: () => void; + hasTVPreferredFocus?: boolean; +}> = ({ label, isActive, onSelect, hasTVPreferredFocus }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new RNAnimated.Value(1)).current; + + const animateTo = (v: number) => + RNAnimated.timing(scale, { + toValue: v, + duration: 120, + easing: RNEasing.out(RNEasing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + onSelect(); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {label} + + + + ); +}; + +// Track option card +const TVTrackCard = React.forwardRef< + View, + { + label: string; + selected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; + } +>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new RNAnimated.Value(1)).current; + + const animateTo = (v: number) => + RNAnimated.timing(scale, { + toValue: v, + duration: 150, + easing: RNEasing.out(RNEasing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {label} + + {selected && !focused && ( + + + + )} + + + ); +}); + // Language selector card const LanguageCard = React.forwardRef< View, @@ -166,7 +305,7 @@ const SubtitleResultCard = React.forwardRef< }, ]} > - {/* Provider/Source badge */} + {/* Provider badge */} - {/* Format */} - {/* Rating if available */} {result.communityRating !== undefined && result.communityRating > 0 && ( @@ -231,7 +368,6 @@ const SubtitleResultCard = React.forwardRef< )} - {/* Download count if available */} {result.downloadCount !== undefined && result.downloadCount > 0 && ( - {/* Loading indicator when downloading */} {isDownloading && ( @@ -316,18 +451,23 @@ const SubtitleResultCard = React.forwardRef< ); }); -export const TVSubtitleSearch: React.FC = ({ +export const TVSubtitleSheet: React.FC = ({ visible, item, mediaSourceId, + subtitleTracks, + currentSubtitleIndex, + onSubtitleChange, onClose, - onServerSubtitleDownloaded, onLocalSubtitleDownloaded, + onServerSubtitleDownloaded, }) => { const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState("tracks"); + const [isReady, setIsReady] = useState(false); const [selectedLanguage, setSelectedLanguage] = useState("eng"); const [downloadingId, setDownloadingId] = useState(null); - const firstResultRef = useRef(null); + const firstTrackCardRef = useRef(null); const { hasOpenSubtitlesApiKey, @@ -347,11 +487,40 @@ export const TVSubtitleSearch: React.FC = ({ 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), + [], + ); + // Animate in/out useEffect(() => { if (visible) { overlayOpacity.setValue(0); sheetTranslateY.setValue(300); + setActiveTab("tracks"); RNAnimated.parallel([ RNAnimated.timing(overlayOpacity, { @@ -367,14 +536,37 @@ export const TVSubtitleSearch: React.FC = ({ useNativeDriver: true, }), ]).start(); - - // Auto-search with default language - search({ language: selectedLanguage }); } else { reset(); } + }, [visible, overlayOpacity, sheetTranslateY, reset]); + + // Delay rendering to work around hasTVPreferredFocus timing issue + useEffect(() => { + if (visible) { + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + } + setIsReady(false); }, [visible]); + // Programmatic focus fallback + useEffect(() => { + if (isReady && firstTrackCardRef.current) { + const timer = setTimeout(() => { + (firstTrackCardRef.current as any)?.requestTVFocus?.(); + }, 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]); + // Handle language selection const handleLanguageSelect = useCallback( (code: string) => { @@ -384,6 +576,15 @@ export const TVSubtitleSearch: React.FC = ({ [search], ); + // Handle track selection + const handleTrackSelect = useCallback( + (index: number) => { + onSubtitleChange(index); + onClose(); + }, + [onSubtitleChange, onClose], + ); + // Handle subtitle download const handleDownload = useCallback( async (result: SubtitleSearchResult) => { @@ -393,11 +594,9 @@ export const TVSubtitleSearch: React.FC = ({ const downloadResult = await downloadAsync(result); if (downloadResult.type === "server") { - // Server-side download - track list should be refreshed - onServerSubtitleDownloaded(); + onServerSubtitleDownloaded?.(); } else if (downloadResult.type === "local" && downloadResult.path) { - // Client-side download - load into MPV - onLocalSubtitleDownloaded(downloadResult.path); + onLocalSubtitleDownloaded?.(downloadResult.path); } onClose(); @@ -415,11 +614,8 @@ export const TVSubtitleSearch: React.FC = ({ ], ); - // Subset of common languages for TV (horizontal scroll works best with fewer items) - const displayLanguages = useMemo( - () => COMMON_SUBTITLE_LANGUAGES.slice(0, 16), - [], - ); + // Whether we're in player context (can use local subtitles) + const isInPlayer = Boolean(onLocalSubtitleDownloaded); if (!visible) return null; @@ -440,133 +636,189 @@ export const TVSubtitleSearch: React.FC = ({ trapFocusRight style={styles.content} > - {/* Header */} - - - {t("player.search_subtitles") || "Search Subtitles"} - - {!hasOpenSubtitlesApiKey && ( - - {t("player.using_jellyfin_server") || "Using Jellyfin Server"} - - )} + {/* Header with title */} + + {t("item_card.subtitles.label").toUpperCase()} + + + {/* Tabs */} + + setActiveTab("tracks")} + hasTVPreferredFocus={true} + /> + setActiveTab("search")} + /> - {/* Language Selector */} - - - {t("player.language") || "Language"} - - - {displayLanguages.map((lang, index) => ( - handleLanguageSelect(lang.code)} - /> - ))} - - - - {/* Results Section */} - - - {t("player.results") || "Results"} - {searchResults && ` (${searchResults.length})`} - - - {/* Loading state */} - {isSearching && ( - - - - {t("player.searching") || "Searching..."} - - - )} - - {/* Error state */} - {searchError && !isSearching && ( - - - - {t("player.search_failed") || "Search failed"} - - - {!hasOpenSubtitlesApiKey - ? t("player.no_subtitle_provider") || - "No subtitle provider configured on server" - : String(searchError)} - - - )} - - {/* No results */} - {searchResults && - searchResults.length === 0 && - !isSearching && - !searchError && ( - - + + {subtitleOptions.map((option, index) => ( + handleTrackSelect(option.value)} /> - - {t("player.no_subtitles_found") || "No subtitles found"} + ))} + + + )} + + {activeTab === "search" && ( + + {/* Download hint - only show on item details page */} + {!isInPlayer && ( + + + + {t("player.subtitle_download_hint") || + "Downloaded subtitles will be saved to your library"} )} - {/* Results list */} - {searchResults && searchResults.length > 0 && !isSearching && ( - - {searchResults.map((result, index) => ( - handleDownload(result)} - /> - ))} - - )} - + {/* Language Selector */} + + + {t("player.language") || "Language"} + + + {displayLanguages.map((lang, index) => ( + handleLanguageSelect(lang.code)} + /> + ))} + + - {/* API Key hint if no fallback available */} - {!hasOpenSubtitlesApiKey && ( - - - - {t("player.add_opensubtitles_key_hint") || - "Add OpenSubtitles API key in settings for client-side fallback"} - + {/* Results Section */} + + + {t("player.results") || "Results"} + {searchResults && ` (${searchResults.length})`} + + + {/* Loading state */} + {isSearching && ( + + + + {t("player.searching") || "Searching..."} + + + )} + + {/* Error state */} + {searchError && !isSearching && ( + + + + {t("player.search_failed") || "Search failed"} + + + {!hasOpenSubtitlesApiKey + ? t("player.no_subtitle_provider") || + "No subtitle provider configured on server" + : String(searchError)} + + + )} + + {/* No results */} + {searchResults && + searchResults.length === 0 && + !isSearching && + !searchError && ( + + + + {t("player.no_subtitles_found") || + "No subtitles found"} + + + )} + + {/* Results list */} + {searchResults && + searchResults.length > 0 && + !isSearching && ( + + {searchResults.map((result, index) => ( + handleDownload(result)} + /> + ))} + + )} + + + {/* API Key hint */} + {!hasOpenSubtitlesApiKey && ( + + + + {t("player.add_opensubtitles_key_hint") || + "Add OpenSubtitles API key in settings for client-side fallback"} + + + )} )} @@ -588,7 +840,7 @@ const styles = StyleSheet.create({ zIndex: 1000, }, sheetContainer: { - maxHeight: "70%", + maxHeight: "75%", }, blurContainer: { borderTopLeftRadius: 24, @@ -598,23 +850,73 @@ const styles = StyleSheet.create({ content: { paddingTop: 24, paddingBottom: 48, - }, - header: { - paddingHorizontal: 48, - marginBottom: 20, + overflow: "visible", }, title: { - fontSize: 24, - fontWeight: "600", - color: "#fff", + fontSize: 18, + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: 16, + paddingHorizontal: 48, + textTransform: "uppercase", + letterSpacing: 1, }, - sourceHint: { - fontSize: 14, + tabRow: { + flexDirection: "row", + paddingHorizontal: 48, + marginBottom: 16, + gap: 24, + }, + tabButton: { + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 8, + borderBottomWidth: 2, + }, + tabText: { + fontSize: 18, + }, + tabContent: { + overflow: "visible", + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: 48, + paddingVertical: 10, + gap: 12, + }, + trackCard: { + width: 180, + height: 80, + borderRadius: 14, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + }, + trackCardText: { + fontSize: 16, + textAlign: "center", + }, + 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)", - marginTop: 4, + fontSize: 14, }, section: { - marginBottom: 20, + marginBottom: 16, }, sectionTitle: { fontSize: 14, @@ -625,9 +927,6 @@ const styles = StyleSheet.create({ marginBottom: 12, paddingHorizontal: 48, }, - languageScroll: { - overflow: "visible", - }, languageScrollContent: { paddingHorizontal: 48, paddingVertical: 8, @@ -649,14 +948,6 @@ const styles = StyleSheet.create({ fontSize: 11, marginTop: 2, }, - checkmark: { - position: "absolute", - top: 6, - right: 6, - }, - resultsScroll: { - overflow: "visible", - }, resultsScrollContent: { paddingHorizontal: 48, paddingVertical: 8, diff --git a/components/music/MusicPlaybackEngine.tsx b/components/music/MusicPlaybackEngine.tsx index ae1b07cd..5fa3dc7e 100644 --- a/components/music/MusicPlaybackEngine.tsx +++ b/components/music/MusicPlaybackEngine.tsx @@ -1,12 +1,5 @@ import { useEffect, useRef } from "react"; -import TrackPlayer, { - Event, - type PlaybackActiveTrackChangedEvent, - State, - useActiveTrack, - usePlaybackState, - useProgress, -} from "react-native-track-player"; +import { Platform } from "react-native"; import { audioStorageEvents, deleteTrack, @@ -14,7 +7,34 @@ import { } from "@/providers/AudioStorage"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; -export const MusicPlaybackEngine: React.FC = () => { +// 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!; + const { position, duration } = useProgress(1000); const playbackState = usePlaybackState(); const activeTrack = useActiveTrack(); @@ -48,7 +68,7 @@ export const MusicPlaybackEngine: React.FC = () => { useEffect(() => { const isPlaying = playbackState.state === State.Playing; setIsPlaying(isPlaying); - }, [playbackState.state, setIsPlaying]); + }, [playbackState.state, setIsPlaying, State.Playing]); // Sync active track changes useEffect(() => { @@ -71,59 +91,63 @@ export const MusicPlaybackEngine: 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) => { - // Trigger look-ahead caching when a new track starts playing - if (event.track) { - triggerLookahead(); + const subscription = TrackPlayer.addEventListener( + Event.PlaybackActiveTrackChanged, + async (event: PlaybackActiveTrackChangedEvent) => { + // 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(); - }, [onTrackEnd, triggerLookahead]); + }, [ + Event.PlaybackActiveTrackChanged, + TrackPlayer, + onTrackEnd, + triggerLookahead, + ]); // Listen for audio cache download completion and update queue URLs useEffect(() => { @@ -141,7 +165,7 @@ export const MusicPlaybackEngine: React.FC = () => { const currentIndex = await TrackPlayer.getActiveTrackIndex(); // Find the track in the queue - const trackIndex = queue.findIndex((t) => t.id === itemId); + const trackIndex = queue.findIndex((t: Track) => t.id === itemId); // Only update if track is in queue and not currently playing if (trackIndex >= 0 && trackIndex !== currentIndex) { @@ -170,13 +194,13 @@ export const MusicPlaybackEngine: React.FC = () => { return () => { audioStorageEvents.off("complete", onComplete); }; - }, []); + }, [TrackPlayer]); // Listen for playback errors (corrupted cache files) useEffect(() => { const subscription = TrackPlayer.addEventListener( Event.PlaybackError, - async (event) => { + async (event: PlaybackErrorEvent) => { const activeTrack = await TrackPlayer.getActiveTrack(); if (!activeTrack?.url) return; @@ -215,8 +239,14 @@ export const MusicPlaybackEngine: 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 fe5df1d6..04b321f2 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -40,6 +40,7 @@ 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"; @@ -52,7 +53,6 @@ import { CONTROLS_CONSTANTS } from "./constants"; import { useRemoteControl } from "./hooks/useRemoteControl"; import { useVideoTime } from "./hooks/useVideoTime"; import { TrickplayBubble } from "./TrickplayBubble"; -import { TVSubtitleSearch } from "./TVSubtitleSearch"; import { useControlsTimeout } from "./useControlsTimeout"; interface Props { @@ -867,7 +867,7 @@ export const Controls: FC = ({ const nextItem = nextItemProp ?? internalNextItem; // Modal state for option selectors - type ModalType = "audio" | "subtitle" | "subtitleSearch" | null; + type ModalType = "audio" | "subtitle" | null; const [openModal, setOpenModal] = useState(null); const isModalOpen = openModal !== null; @@ -910,36 +910,12 @@ export const Controls: FC = ({ })); }, [audioTracks, audioIndex]); - // Subtitle options for selector (with "None" option) - const subtitleOptions = useMemo(() => { - const noneOption = { - label: t("item_card.subtitles.none"), - value: -1, - selected: subtitleIndex === -1, - }; - const trackOptions = subtitleTracks.map((track) => ({ - label: - track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, - value: track.Index!, - selected: track.Index === subtitleIndex, - })); - return [noneOption, ...trackOptions]; - }, [subtitleTracks, subtitleIndex, t]); - // Get display labels for buttons const _selectedAudioLabel = useMemo(() => { const track = audioTracks.find((t) => t.Index === audioIndex); 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) => { @@ -1074,25 +1050,6 @@ export const Controls: FC = ({ controlsInteractionRef.current(); }, []); - const handleOpenSubtitleSearch = useCallback(() => { - setLastOpenedModal("subtitleSearch"); - setOpenModal("subtitleSearch"); - 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); @@ -1454,26 +1411,13 @@ export const Controls: FC = ({ /> )} - {/* Subtitle button - only show when subtitle tracks are available */} - {subtitleTracks.length > 0 && ( - - )} - - {/* Subtitle Search button */} + {/* Subtitle button - always show to allow search even if no tracks */} @@ -1540,22 +1484,17 @@ export const Controls: FC = ({ onClose={() => setOpenModal(null)} /> - setOpenModal(null)} - /> - - {/* Subtitle Search Modal */} - setOpenModal(null)} - onServerSubtitleDownloaded={handleServerSubtitleDownloaded} - onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded} + onServerSubtitleDownloaded={onServerSubtitleDownloaded} + onLocalSubtitleDownloaded={addSubtitleFile} /> ); diff --git a/index.js b/index.js index 7a0294a3..94c4bfe2 100644 --- a/index.js +++ b/index.js @@ -2,9 +2,13 @@ import "react-native-url-polyfill/auto"; import { Platform } from "react-native"; import "expo-router/entry"; -// TrackPlayer is not supported on tvOS +// TrackPlayer is not supported on tvOS - wrap in try-catch in case native module isn't linked if (!Platform.isTV) { - const TrackPlayer = require("react-native-track-player").default; - const { PlaybackService } = require("./services/PlaybackService"); - TrackPlayer.registerPlaybackService(() => PlaybackService); + 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); + } } diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index ad61bc64..454ccc82 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -4,12 +4,21 @@ import CoreMedia import CoreVideo import AVFoundation +/// HDR mode detected from video properties +enum HDRMode { + case sdr + case hdr10 + case dolbyVision + case hlg +} + protocol MPVLayerRendererDelegate: AnyObject { func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool) func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool) + func renderer(_ renderer: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double) } /// MPV player using vo_avfoundation for video output. @@ -427,7 +436,10 @@ final class MPVLayerRenderer { self.delegate?.renderer(self, didChangeLoading: false) } } - + + // Detect HDR mode for tvOS display switching + detectHDRMode() + case MPV_EVENT_SEEK: // Seek started - show loading indicator and enable immediate progress updates isSeeking = true @@ -793,6 +805,53 @@ final class MPVLayerRenderer { return Int(aid) } + // MARK: - HDR Detection + + /// Detects the HDR mode of the currently playing video by reading mpv properties + private func detectHDRMode() { + guard let handle = mpv else { return } + + // Get video color properties + let primaries = getStringProperty(handle: handle, name: "video-params/primaries") + let gamma = getStringProperty(handle: handle, name: "video-params/gamma") + + // Get FPS for display criteria + var fps: Double = 24.0 + getProperty(handle: handle, name: "container-fps", format: MPV_FORMAT_DOUBLE, value: &fps) + if fps <= 0 { fps = 24.0 } + + Logger.shared.log("HDR Detection - primaries: \(primaries ?? "nil"), gamma: \(gamma ?? "nil"), fps: \(fps)", type: "Info") + + // Determine HDR mode based on color properties + // bt.2020 primaries with PQ gamma = HDR10 or Dolby Vision + // bt.2020 primaries with HLG gamma = HLG + // Otherwise SDR + let hdrMode: HDRMode + + if primaries == "bt.2020" || primaries == "bt.2020-ncl" { + if gamma == "pq" { + // PQ gamma indicates HDR10 or Dolby Vision + // We'll use hdr10 as the base, Dolby Vision detection would need codec inspection + // For DV Profile 8.1, HDR10 fallback should work + hdrMode = .hdr10 + } else if gamma == "hlg" { + hdrMode = .hlg + } else { + // bt.2020 without HDR gamma - still request HDR mode for wide color + hdrMode = .hdr10 + } + } else { + hdrMode = .sdr + } + + Logger.shared.log("HDR Detection - detected mode: \(hdrMode)", type: "Info") + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didDetectHDRMode: hdrMode, fps: fps) + } + } + // MARK: - Technical Info func getTechnicalInfo() -> [String: Any] { diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index e21978d1..05011197 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -308,6 +308,9 @@ class MpvPlayerView: ExpoView { } deinit { + #if os(tvOS) + resetDisplayCriteria() + #endif pipController?.stopPictureInPicture() renderer?.stop() displayLayer.removeFromSuperlayer() @@ -376,8 +379,63 @@ extension MpvPlayerView: MPVLayerRendererDelegate { self.onTracksReady([:]) } } + + func renderer(_: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double) { + #if os(tvOS) + setDisplayCriteria(for: mode, fps: Float(fps)) + #endif + } } +// MARK: - tvOS HDR Display Criteria + +#if os(tvOS) +import AVKit + +extension MpvPlayerView { + /// Sets the preferred display criteria for HDR content on tvOS + func setDisplayCriteria(for hdrMode: HDRMode, fps: Float) { + guard let window = self.window else { + print("🎬 HDR: No window available for display criteria") + return + } + + let manager = window.avDisplayManager + + switch hdrMode { + case .sdr: + print("🎬 HDR: Setting display criteria to SDR (nil)") + manager.preferredDisplayCriteria = nil + case .hdr10: + print("🎬 HDR: Setting display criteria to HDR10, fps: \(fps)") + manager.preferredDisplayCriteria = AVDisplayCriteria( + refreshRate: fps, + videoDynamicRange: "hdr10" + ) + case .dolbyVision: + print("🎬 HDR: Setting display criteria to Dolby Vision, fps: \(fps)") + manager.preferredDisplayCriteria = AVDisplayCriteria( + refreshRate: fps, + videoDynamicRange: "dolbyVision" + ) + case .hlg: + print("🎬 HDR: Setting display criteria to HLG, fps: \(fps)") + manager.preferredDisplayCriteria = AVDisplayCriteria( + refreshRate: fps, + videoDynamicRange: "hlg" + ) + } + } + + /// Resets display criteria when playback ends + func resetDisplayCriteria() { + guard let window = self.window else { return } + print("🎬 HDR: Resetting display criteria") + window.avDisplayManager.preferredDisplayCriteria = nil + } +} +#endif + // MARK: - PiPControllerDelegate extension MpvPlayerView: PiPControllerDelegate { diff --git a/providers/MusicPlayerProvider.tsx b/providers/MusicPlayerProvider.tsx index 71bce6e1..13584b8c 100644 --- a/providers/MusicPlayerProvider.tsx +++ b/providers/MusicPlayerProvider.tsx @@ -30,20 +30,32 @@ import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; import { storage } from "@/utils/mmkv"; // Conditionally import TrackPlayer only on non-TV platforms -// 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"); +// 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; // Extract types and enums from the module (only available on non-TV) const Capability = TrackPlayerModule?.Capability; const TPRepeatMode = TrackPlayerModule?.RepeatMode; -type Track = NonNullable["Track"]; -type Progress = NonNullable["Progress"]; + +// 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 }; // Storage keys const STORAGE_KEYS = { @@ -382,7 +394,7 @@ const MobileMusicPlayerProvider: React.FC = ({ // Setup TrackPlayer and AudioStorage useEffect(() => { - if (!TrackPlayer) return; + if (!TrackPlayer || !Capability) return; const setupPlayer = async () => { if (playerSetupRef.current) return; @@ -432,21 +444,21 @@ const MobileMusicPlayerProvider: React.FC = ({ // Sync repeat mode to TrackPlayer useEffect(() => { - if (!TrackPlayer) return; + if (!TrackPlayer || !TPRepeatMode) return; const syncRepeatMode = async () => { if (!playerSetupRef.current) return; - let tpRepeatMode: typeof TPRepeatMode; + let tpRepeatMode: number; 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 554d4476..35e95a1b 100644 --- a/services/PlaybackService.ts +++ b/services/PlaybackService.ts @@ -1,6 +1,22 @@ -import TrackPlayer, { Event } from "react-native-track-player"; +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); + } +} 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()); @@ -13,8 +29,9 @@ export const PlaybackService = async () => { TrackPlayer.skipToPrevious(), ); - TrackPlayer.addEventListener(Event.RemoteSeek, (event) => - TrackPlayer.seekTo(event.position), + TrackPlayer.addEventListener( + Event.RemoteSeek, + (event: { position: number }) => TrackPlayer.seekTo(event.position), ); TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.reset()); diff --git a/translations/en.json b/translations/en.json index ee7a42c7..991dbf52 100644 --- a/translations/en.json +++ b/translations/en.json @@ -260,7 +260,12 @@ "subtitle_font": "Subtitle Font", "ksplayer_title": "KSPlayer Settings", "hardware_decode": "Hardware Decoding", - "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues." + "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.", + "opensubtitles_title": "OpenSubtitles", + "opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.", + "opensubtitles_api_key": "API Key", + "opensubtitles_api_key_placeholder": "Enter API key...", + "opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers" }, "vlc_subtitles": { "title": "VLC Subtitle Settings", @@ -619,7 +624,19 @@ "downloaded_file_no": "No", "downloaded_file_cancel": "Cancel", "swipe_down_settings": "Swipe down for settings", - "ends_at": "ends at" + "ends_at": "ends at", + "search_subtitles": "Search Subtitles", + "subtitle_tracks": "Tracks", + "subtitle_search": "Search & Download", + "subtitle_download_hint": "Downloaded subtitles will be saved to your library", + "using_jellyfin_server": "Using Jellyfin Server", + "language": "Language", + "results": "Results", + "searching": "Searching...", + "search_failed": "Search failed", + "no_subtitle_provider": "No subtitle provider configured on server", + "no_subtitles_found": "No subtitles found", + "add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback" }, "item_card": { "next_up": "Next Up",