mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-08 13:32:22 +00:00
refactor(tv): unify subtitle track selector and search into tabbed sheet
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
12
index.js
12
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user