refactor(tv): unify subtitle track selector and search into tabbed sheet

This commit is contained in:
Fredrik Burmester
2026-01-18 11:13:57 +01:00
parent ee3a288fa0
commit c515d037cf
12 changed files with 826 additions and 399 deletions

View File

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

View File

@@ -6,6 +6,7 @@ import {
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import {
getMediaInfoApi,
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
@@ -934,16 +935,47 @@ export default function page() {
}, []);
// TV: Handle server-side subtitle download (needs media source refresh)
// 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
}, []);
// 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(() => {

View File

@@ -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<ItemContentTVProps> = 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<ItemContentTVProps> = 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<ItemContentTVProps> = 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<ItemContentTVProps> = React.memo(
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
// Determine which option button is the last one (for focus guide targeting)
// Subtitle is always shown now (always has search capability)
const lastOptionButton = useMemo(() => {
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<ItemContentTVProps> = React.memo(
/>
)}
{/* Subtitle selector */}
{(subtitleTracks.length > 0 ||
selectedOptions?.subtitleIndex !== undefined) && (
<TVOptionButton
ref={
lastOptionButton === "subtitle"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.subtitles.label")}
value={selectedSubtitleLabel}
onPress={() => setOpenModal("subtitle")}
/>
)}
{/* Subtitle selector - always show to enable search */}
<TVOptionButton
ref={
lastOptionButton === "subtitle"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.subtitles.label")}
value={selectedSubtitleLabel}
onPress={() => setOpenModal("subtitle")}
/>
</View>
{/* Focus guide to direct navigation from options to cast list */}
@@ -1742,12 +1721,16 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
onClose={() => setOpenModal(null)}
/>
<TVOptionSelector
{/* Subtitle Sheet with tabs for tracks and search */}
<TVSubtitleSheet
visible={openModal === "subtitle"}
title={t("item_card.subtitles.label")}
options={subtitleOptions}
onSelect={handleSubtitleChange}
item={item}
mediaSourceId={selectedOptions?.mediaSource?.Id}
subtitleTracks={subtitleTracks}
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
onSubtitleChange={handleSubtitleChange}
onClose={() => setOpenModal(null)}
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
/>
</View>
);

View File

@@ -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 (
<Pressable
onFocus={() => {
setFocused(true);
animateTo(1.05);
onSelect();
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<RNAnimated.View
style={[
styles.tabButton,
{
transform: [{ scale }],
backgroundColor: focused
? "#fff"
: isActive
? "rgba(255,255,255,0.2)"
: "transparent",
borderBottomColor: isActive ? "#fff" : "transparent",
},
]}
>
<Text
style={[
styles.tabText,
{ color: focused ? "#000" : "#fff" },
(focused || isActive) && { fontWeight: "600" },
]}
>
{label}
</Text>
</RNAnimated.View>
</Pressable>
);
};
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<RNAnimated.View
style={[
styles.trackCard,
{
transform: [{ scale }],
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.trackCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={2}
>
{label}
</Text>
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</RNAnimated.View>
</Pressable>
);
});
// Language selector card
const LanguageCard = React.forwardRef<
View,
@@ -166,7 +305,7 @@ const SubtitleResultCard = React.forwardRef<
},
]}
>
{/* Provider/Source badge */}
{/* Provider badge */}
<View
style={[
styles.providerBadge,
@@ -197,7 +336,6 @@ const SubtitleResultCard = React.forwardRef<
{/* Meta info row */}
<View style={styles.resultMeta}>
{/* Format */}
<Text
style={[
styles.resultMetaText,
@@ -207,7 +345,6 @@ const SubtitleResultCard = React.forwardRef<
{result.format?.toUpperCase()}
</Text>
{/* Rating if available */}
{result.communityRating !== undefined &&
result.communityRating > 0 && (
<View style={styles.ratingContainer}>
@@ -231,7 +368,6 @@ const SubtitleResultCard = React.forwardRef<
</View>
)}
{/* Download count if available */}
{result.downloadCount !== undefined && result.downloadCount > 0 && (
<View style={styles.downloadCountContainer}>
<Ionicons
@@ -305,7 +441,6 @@ const SubtitleResultCard = React.forwardRef<
)}
</View>
{/* Loading indicator when downloading */}
{isDownloading && (
<View style={styles.downloadingOverlay}>
<ActivityIndicator size='small' color='#fff' />
@@ -316,18 +451,23 @@ const SubtitleResultCard = React.forwardRef<
);
});
export const TVSubtitleSearch: React.FC<Props> = ({
export const TVSubtitleSheet: React.FC<Props> = ({
visible,
item,
mediaSourceId,
subtitleTracks,
currentSubtitleIndex,
onSubtitleChange,
onClose,
onServerSubtitleDownloaded,
onLocalSubtitleDownloaded,
onServerSubtitleDownloaded,
}) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [isReady, setIsReady] = useState(false);
const [selectedLanguage, setSelectedLanguage] = useState("eng");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const firstResultRef = useRef<View>(null);
const firstTrackCardRef = useRef<View>(null);
const {
hasOpenSubtitlesApiKey,
@@ -347,11 +487,40 @@ export const TVSubtitleSearch: React.FC<Props> = ({
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<Props> = ({
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<Props> = ({
[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<Props> = ({
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<Props> = ({
],
);
// 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<Props> = ({
trapFocusRight
style={styles.content}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>
{t("player.search_subtitles") || "Search Subtitles"}
</Text>
{!hasOpenSubtitlesApiKey && (
<Text style={styles.sourceHint}>
{t("player.using_jellyfin_server") || "Using Jellyfin Server"}
</Text>
)}
{/* Header with title */}
<Text style={styles.title}>
{t("item_card.subtitles.label").toUpperCase()}
</Text>
{/* Tabs */}
<View style={styles.tabRow}>
<TVSubtitleTab
label={t("player.subtitle_tracks") || "Tracks"}
isActive={activeTab === "tracks"}
onSelect={() => setActiveTab("tracks")}
hasTVPreferredFocus={true}
/>
<TVSubtitleTab
label={t("player.subtitle_search") || "Search & Download"}
isActive={activeTab === "search"}
onSelect={() => setActiveTab("search")}
/>
</View>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.language") || "Language"}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.languageScroll}
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<LanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
selected={selectedLanguage === lang.code}
hasTVPreferredFocus={
index === 0 &&
(!searchResults || searchResults.length === 0)
}
onPress={() => handleLanguageSelect(lang.code)}
/>
))}
</ScrollView>
</View>
{/* Results Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`}
</Text>
{/* Loading state */}
{isSearching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#fff' />
<Text style={styles.loadingText}>
{t("player.searching") || "Searching..."}
</Text>
</View>
)}
{/* Error state */}
{searchError && !isSearching && (
<View style={styles.errorContainer}>
<Ionicons
name='alert-circle-outline'
size={32}
color='rgba(255,100,100,0.8)'
/>
<Text style={styles.errorText}>
{t("player.search_failed") || "Search failed"}
</Text>
<Text style={styles.errorHint}>
{!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server"
: String(searchError)}
</Text>
</View>
)}
{/* No results */}
{searchResults &&
searchResults.length === 0 &&
!isSearching &&
!searchError && (
<View style={styles.emptyContainer}>
<Ionicons
name='document-text-outline'
size={32}
color='rgba(255,255,255,0.4)'
{/* Tab Content */}
{activeTab === "tracks" && isReady && (
<View style={styles.tabContent}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{subtitleOptions.map((option, index) => (
<TVTrackCard
key={index}
ref={
index === initialSelectedIndex
? firstTrackCardRef
: undefined
}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => handleTrackSelect(option.value)}
/>
<Text style={styles.emptyText}>
{t("player.no_subtitles_found") || "No subtitles found"}
))}
</ScrollView>
</View>
)}
{activeTab === "search" && (
<View style={styles.tabContent}>
{/* Download hint - only show on item details page */}
{!isInPlayer && (
<View style={styles.downloadHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.5)'
/>
<Text style={styles.downloadHintText}>
{t("player.subtitle_download_hint") ||
"Downloaded subtitles will be saved to your library"}
</Text>
</View>
)}
{/* Results list */}
{searchResults && searchResults.length > 0 && !isSearching && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.resultsScroll}
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<SubtitleResultCard
key={result.id}
ref={index === 0 ? firstResultRef : undefined}
result={result}
hasTVPreferredFocus={index === 0}
isDownloading={downloadingId === result.id}
onPress={() => handleDownload(result)}
/>
))}
</ScrollView>
)}
</View>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.language") || "Language"}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<LanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
selected={selectedLanguage === lang.code}
hasTVPreferredFocus={
index === 0 &&
(!searchResults || searchResults.length === 0)
}
onPress={() => handleLanguageSelect(lang.code)}
/>
))}
</ScrollView>
</View>
{/* API Key hint if no fallback available */}
{!hasOpenSubtitlesApiKey && (
<View style={styles.apiKeyHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.apiKeyHintText}>
{t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"}
</Text>
{/* Results Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`}
</Text>
{/* Loading state */}
{isSearching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#fff' />
<Text style={styles.loadingText}>
{t("player.searching") || "Searching..."}
</Text>
</View>
)}
{/* Error state */}
{searchError && !isSearching && (
<View style={styles.errorContainer}>
<Ionicons
name='alert-circle-outline'
size={32}
color='rgba(255,100,100,0.8)'
/>
<Text style={styles.errorText}>
{t("player.search_failed") || "Search failed"}
</Text>
<Text style={styles.errorHint}>
{!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server"
: String(searchError)}
</Text>
</View>
)}
{/* No results */}
{searchResults &&
searchResults.length === 0 &&
!isSearching &&
!searchError && (
<View style={styles.emptyContainer}>
<Ionicons
name='document-text-outline'
size={32}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.emptyText}>
{t("player.no_subtitles_found") ||
"No subtitles found"}
</Text>
</View>
)}
{/* Results list */}
{searchResults &&
searchResults.length > 0 &&
!isSearching && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<SubtitleResultCard
key={result.id}
result={result}
hasTVPreferredFocus={index === 0}
isDownloading={downloadingId === result.id}
onPress={() => handleDownload(result)}
/>
))}
</ScrollView>
)}
</View>
{/* API Key hint */}
{!hasOpenSubtitlesApiKey && (
<View style={styles.apiKeyHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.apiKeyHintText}>
{t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"}
</Text>
</View>
)}
</View>
)}
</TVFocusGuideView>
@@ -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,

View File

@@ -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<PlaybackActiveTrackChangedEvent>(
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;

View File

@@ -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<Props> = ({
const nextItem = nextItemProp ?? internalNextItem;
// Modal state for option selectors
type ModalType = "audio" | "subtitle" | "subtitleSearch" | null;
type ModalType = "audio" | "subtitle" | null;
const [openModal, setOpenModal] = useState<ModalType>(null);
const isModalOpen = openModal !== null;
@@ -910,36 +910,12 @@ export const Controls: FC<Props> = ({
}));
}, [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<Props> = ({
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<Props> = ({
/>
)}
{/* Subtitle button - only show when subtitle tracks are available */}
{subtitleTracks.length > 0 && (
<TVControlButton
icon='text'
onPress={handleOpenSubtitleSheet}
disabled={isModalOpen}
hasTVPreferredFocus={
!isModalOpen && lastOpenedModal === "subtitle"
}
size={24}
/>
)}
{/* Subtitle Search button */}
{/* Subtitle button - always show to allow search even if no tracks */}
<TVControlButton
icon='download-outline'
onPress={handleOpenSubtitleSearch}
icon='text'
onPress={handleOpenSubtitleSheet}
disabled={isModalOpen}
hasTVPreferredFocus={
!isModalOpen && lastOpenedModal === "subtitleSearch"
!isModalOpen && lastOpenedModal === "subtitle"
}
size={24}
/>
@@ -1540,22 +1484,17 @@ export const Controls: FC<Props> = ({
onClose={() => setOpenModal(null)}
/>
<TVOptionSelector
{/* Subtitle Sheet with tabs for tracks and search */}
<TVSubtitleSheet
visible={openModal === "subtitle"}
title={t("item_card.subtitles.label")}
options={subtitleOptions}
onSelect={handleSubtitleChange}
onClose={() => setOpenModal(null)}
/>
{/* Subtitle Search Modal */}
<TVSubtitleSearch
visible={openModal === "subtitleSearch"}
item={item}
mediaSourceId={mediaSource?.Id}
subtitleTracks={subtitleTracks}
currentSubtitleIndex={subtitleIndex ?? -1}
onSubtitleChange={handleSubtitleChange}
onClose={() => setOpenModal(null)}
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded}
onServerSubtitleDownloaded={onServerSubtitleDownloaded}
onLocalSubtitleDownloaded={addSubtitleFile}
/>
</View>
);

View File

@@ -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);
}
}

View File

@@ -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] {

View File

@@ -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 {

View File

@@ -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<typeof TrackPlayerModule>["Track"];
type Progress = NonNullable<typeof TrackPlayerModule>["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<MusicPlayerProviderProps> = ({
// 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<MusicPlayerProviderProps> = ({
// 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);
};

View File

@@ -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());

View File

@@ -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",