mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-07 13:02:52 +01: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}
|
disabled={isModalOpen}
|
||||||
/>
|
/>
|
||||||
<TVSettingsStepper
|
|
||||||
label={t("home.settings.subtitles.subtitle_size")}
|
|
||||||
value={settings.subtitleSize / 100}
|
|
||||||
onDecrease={() => {
|
|
||||||
const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1);
|
|
||||||
updateSettings({ subtitleSize: Math.round(newValue * 100) });
|
|
||||||
}}
|
|
||||||
onIncrease={() => {
|
|
||||||
const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1);
|
|
||||||
updateSettings({ subtitleSize: Math.round(newValue * 100) });
|
|
||||||
}}
|
|
||||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
|
||||||
disabled={isModalOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* MPV Subtitles Section */}
|
{/* MPV Subtitles Section */}
|
||||||
<SectionHeader title='MPV Subtitle Settings' />
|
<SectionHeader title='MPV Subtitle Settings' />
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
RepeatMode,
|
RepeatMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import {
|
import {
|
||||||
|
getMediaInfoApi,
|
||||||
getPlaystateApi,
|
getPlaystateApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
@@ -934,16 +935,47 @@ export default function page() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// TV: Handle server-side subtitle download (needs media source refresh)
|
// TV: Handle server-side subtitle download (needs media source refresh)
|
||||||
// Note: After downloading via Jellyfin API, the subtitle appears in the track list
|
// After downloading via Jellyfin API, we need to refresh the media source
|
||||||
// but we need to re-fetch the media source to see it. For now, we just log a message.
|
// to see the new subtitle in the track list.
|
||||||
// A full implementation would refetch getStreamUrl and update the stream state.
|
const handleServerSubtitleDownloaded = useCallback(async () => {
|
||||||
const handleServerSubtitleDownloaded = useCallback(() => {
|
if (!api || !user?.Id || !item?.Id || !stream) {
|
||||||
console.log(
|
console.warn("Cannot refresh media source: missing required data");
|
||||||
"Server-side subtitle downloaded - track list should be refreshed",
|
return;
|
||||||
);
|
}
|
||||||
// TODO: Implement media source refresh to pick up new subtitle
|
|
||||||
// This would involve re-calling getStreamUrl and updating the stream state
|
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
|
// TV: Navigate to next item
|
||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
@@ -31,6 +32,7 @@ import { Badge } from "@/components/Badge";
|
|||||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVSubtitleSheet } from "@/components/common/TVSubtitleSheet";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
@@ -856,6 +858,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const _itemColors = useImageColorsReturn({ item });
|
const _itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
@@ -969,23 +972,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
}));
|
}));
|
||||||
}, [audioTracks, selectedOptions?.audioIndex]);
|
}, [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
|
// Media source options for selector
|
||||||
const mediaSourceOptions = useMemo(() => {
|
const mediaSourceOptions = useMemo(() => {
|
||||||
return mediaSources.map((source) => {
|
return mediaSources.map((source) => {
|
||||||
@@ -1051,6 +1037,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined));
|
setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Refresh item data when server-side subtitle is downloaded
|
||||||
|
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
|
// Get display values for buttons
|
||||||
const selectedAudioLabel = useMemo(() => {
|
const selectedAudioLabel = useMemo(() => {
|
||||||
const track = audioTracks.find(
|
const track = audioTracks.find(
|
||||||
@@ -1121,23 +1115,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
|
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
|
||||||
|
|
||||||
// Determine which option button is the last one (for focus guide targeting)
|
// Determine which option button is the last one (for focus guide targeting)
|
||||||
|
// Subtitle is always shown now (always has search capability)
|
||||||
const lastOptionButton = useMemo(() => {
|
const lastOptionButton = useMemo(() => {
|
||||||
const hasSubtitleOption =
|
// Subtitle is always the last button since it's always shown
|
||||||
subtitleTracks.length > 0 ||
|
return "subtitle";
|
||||||
selectedOptions?.subtitleIndex !== undefined;
|
}, []);
|
||||||
const hasAudioOption = audioTracks.length > 0;
|
|
||||||
const hasMediaSourceOption = mediaSources.length > 1;
|
|
||||||
|
|
||||||
if (hasSubtitleOption) return "subtitle";
|
|
||||||
if (hasAudioOption) return "audio";
|
|
||||||
if (hasMediaSourceOption) return "mediaSource";
|
|
||||||
return "quality";
|
|
||||||
}, [
|
|
||||||
subtitleTracks.length,
|
|
||||||
selectedOptions?.subtitleIndex,
|
|
||||||
audioTracks.length,
|
|
||||||
mediaSources.length,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!item || !selectedOptions) return null;
|
if (!item || !selectedOptions) return null;
|
||||||
|
|
||||||
@@ -1426,20 +1408,17 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subtitle selector */}
|
{/* Subtitle selector - always show to enable search */}
|
||||||
{(subtitleTracks.length > 0 ||
|
<TVOptionButton
|
||||||
selectedOptions?.subtitleIndex !== undefined) && (
|
ref={
|
||||||
<TVOptionButton
|
lastOptionButton === "subtitle"
|
||||||
ref={
|
? setLastOptionButtonRef
|
||||||
lastOptionButton === "subtitle"
|
: undefined
|
||||||
? setLastOptionButtonRef
|
}
|
||||||
: undefined
|
label={t("item_card.subtitles.label")}
|
||||||
}
|
value={selectedSubtitleLabel}
|
||||||
label={t("item_card.subtitles.label")}
|
onPress={() => setOpenModal("subtitle")}
|
||||||
value={selectedSubtitleLabel}
|
/>
|
||||||
onPress={() => setOpenModal("subtitle")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Focus guide to direct navigation from options to cast list */}
|
{/* Focus guide to direct navigation from options to cast list */}
|
||||||
@@ -1742,12 +1721,16 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
onClose={() => setOpenModal(null)}
|
onClose={() => setOpenModal(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TVOptionSelector
|
{/* Subtitle Sheet with tabs for tracks and search */}
|
||||||
|
<TVSubtitleSheet
|
||||||
visible={openModal === "subtitle"}
|
visible={openModal === "subtitle"}
|
||||||
title={t("item_card.subtitles.label")}
|
item={item}
|
||||||
options={subtitleOptions}
|
mediaSourceId={selectedOptions?.mediaSource?.Id}
|
||||||
onSelect={handleSubtitleChange}
|
subtitleTracks={subtitleTracks}
|
||||||
|
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
|
||||||
|
onSubtitleChange={handleSubtitleChange}
|
||||||
onClose={() => setOpenModal(null)}
|
onClose={() => setOpenModal(null)}
|
||||||
|
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
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 { BlurView } from "expo-blur";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -30,13 +33,149 @@ interface Props {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
mediaSourceId?: string | null;
|
mediaSourceId?: string | null;
|
||||||
|
subtitleTracks: MediaStream[];
|
||||||
|
currentSubtitleIndex: number;
|
||||||
|
onSubtitleChange: (index: number) => void;
|
||||||
onClose: () => 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) */
|
/** Called when a subtitle is downloaded via Jellyfin API (server-side) */
|
||||||
onServerSubtitleDownloaded: () => void;
|
onServerSubtitleDownloaded?: () => void;
|
||||||
/** Called when a subtitle is downloaded locally (client-side) */
|
|
||||||
onLocalSubtitleDownloaded: (path: string) => 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
|
// Language selector card
|
||||||
const LanguageCard = React.forwardRef<
|
const LanguageCard = React.forwardRef<
|
||||||
View,
|
View,
|
||||||
@@ -166,7 +305,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* Provider/Source badge */}
|
{/* Provider badge */}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.providerBadge,
|
styles.providerBadge,
|
||||||
@@ -197,7 +336,6 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
|
|
||||||
{/* Meta info row */}
|
{/* Meta info row */}
|
||||||
<View style={styles.resultMeta}>
|
<View style={styles.resultMeta}>
|
||||||
{/* Format */}
|
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.resultMetaText,
|
styles.resultMetaText,
|
||||||
@@ -207,7 +345,6 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
{result.format?.toUpperCase()}
|
{result.format?.toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Rating if available */}
|
|
||||||
{result.communityRating !== undefined &&
|
{result.communityRating !== undefined &&
|
||||||
result.communityRating > 0 && (
|
result.communityRating > 0 && (
|
||||||
<View style={styles.ratingContainer}>
|
<View style={styles.ratingContainer}>
|
||||||
@@ -231,7 +368,6 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Download count if available */}
|
|
||||||
{result.downloadCount !== undefined && result.downloadCount > 0 && (
|
{result.downloadCount !== undefined && result.downloadCount > 0 && (
|
||||||
<View style={styles.downloadCountContainer}>
|
<View style={styles.downloadCountContainer}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@@ -305,7 +441,6 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Loading indicator when downloading */}
|
|
||||||
{isDownloading && (
|
{isDownloading && (
|
||||||
<View style={styles.downloadingOverlay}>
|
<View style={styles.downloadingOverlay}>
|
||||||
<ActivityIndicator size='small' color='#fff' />
|
<ActivityIndicator size='small' color='#fff' />
|
||||||
@@ -316,18 +451,23 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TVSubtitleSearch: React.FC<Props> = ({
|
export const TVSubtitleSheet: React.FC<Props> = ({
|
||||||
visible,
|
visible,
|
||||||
item,
|
item,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
|
subtitleTracks,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
onSubtitleChange,
|
||||||
onClose,
|
onClose,
|
||||||
onServerSubtitleDownloaded,
|
|
||||||
onLocalSubtitleDownloaded,
|
onLocalSubtitleDownloaded,
|
||||||
|
onServerSubtitleDownloaded,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
||||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||||
const firstResultRef = useRef<View>(null);
|
const firstTrackCardRef = useRef<View>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
hasOpenSubtitlesApiKey,
|
hasOpenSubtitlesApiKey,
|
||||||
@@ -347,11 +487,40 @@ export const TVSubtitleSearch: React.FC<Props> = ({
|
|||||||
const overlayOpacity = useRef(new RNAnimated.Value(0)).current;
|
const overlayOpacity = useRef(new RNAnimated.Value(0)).current;
|
||||||
const sheetTranslateY = useRef(new RNAnimated.Value(300)).current;
|
const sheetTranslateY = useRef(new RNAnimated.Value(300)).current;
|
||||||
|
|
||||||
|
// Build subtitle options (with "None" option)
|
||||||
|
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
|
// Animate in/out
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
overlayOpacity.setValue(0);
|
overlayOpacity.setValue(0);
|
||||||
sheetTranslateY.setValue(300);
|
sheetTranslateY.setValue(300);
|
||||||
|
setActiveTab("tracks");
|
||||||
|
|
||||||
RNAnimated.parallel([
|
RNAnimated.parallel([
|
||||||
RNAnimated.timing(overlayOpacity, {
|
RNAnimated.timing(overlayOpacity, {
|
||||||
@@ -367,14 +536,37 @@ export const TVSubtitleSearch: React.FC<Props> = ({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
|
|
||||||
// Auto-search with default language
|
|
||||||
search({ language: selectedLanguage });
|
|
||||||
} else {
|
} else {
|
||||||
reset();
|
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]);
|
}, [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
|
// Handle language selection
|
||||||
const handleLanguageSelect = useCallback(
|
const handleLanguageSelect = useCallback(
|
||||||
(code: string) => {
|
(code: string) => {
|
||||||
@@ -384,6 +576,15 @@ export const TVSubtitleSearch: React.FC<Props> = ({
|
|||||||
[search],
|
[search],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle track selection
|
||||||
|
const handleTrackSelect = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
onSubtitleChange(index);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[onSubtitleChange, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle subtitle download
|
// Handle subtitle download
|
||||||
const handleDownload = useCallback(
|
const handleDownload = useCallback(
|
||||||
async (result: SubtitleSearchResult) => {
|
async (result: SubtitleSearchResult) => {
|
||||||
@@ -393,11 +594,9 @@ export const TVSubtitleSearch: React.FC<Props> = ({
|
|||||||
const downloadResult = await downloadAsync(result);
|
const downloadResult = await downloadAsync(result);
|
||||||
|
|
||||||
if (downloadResult.type === "server") {
|
if (downloadResult.type === "server") {
|
||||||
// Server-side download - track list should be refreshed
|
onServerSubtitleDownloaded?.();
|
||||||
onServerSubtitleDownloaded();
|
|
||||||
} else if (downloadResult.type === "local" && downloadResult.path) {
|
} else if (downloadResult.type === "local" && downloadResult.path) {
|
||||||
// Client-side download - load into MPV
|
onLocalSubtitleDownloaded?.(downloadResult.path);
|
||||||
onLocalSubtitleDownloaded(downloadResult.path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
@@ -415,11 +614,8 @@ export const TVSubtitleSearch: React.FC<Props> = ({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subset of common languages for TV (horizontal scroll works best with fewer items)
|
// Whether we're in player context (can use local subtitles)
|
||||||
const displayLanguages = useMemo(
|
const isInPlayer = Boolean(onLocalSubtitleDownloaded);
|
||||||
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
@@ -440,133 +636,189 @@ export const TVSubtitleSearch: React.FC<Props> = ({
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header with title */}
|
||||||
<View style={styles.header}>
|
<Text style={styles.title}>
|
||||||
<Text style={styles.title}>
|
{t("item_card.subtitles.label").toUpperCase()}
|
||||||
{t("player.search_subtitles") || "Search Subtitles"}
|
</Text>
|
||||||
</Text>
|
|
||||||
{!hasOpenSubtitlesApiKey && (
|
{/* Tabs */}
|
||||||
<Text style={styles.sourceHint}>
|
<View style={styles.tabRow}>
|
||||||
{t("player.using_jellyfin_server") || "Using Jellyfin Server"}
|
<TVSubtitleTab
|
||||||
</Text>
|
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>
|
</View>
|
||||||
|
|
||||||
{/* Language Selector */}
|
{/* Tab Content */}
|
||||||
<View style={styles.section}>
|
{activeTab === "tracks" && isReady && (
|
||||||
<Text style={styles.sectionTitle}>
|
<View style={styles.tabContent}>
|
||||||
{t("player.language") || "Language"}
|
<ScrollView
|
||||||
</Text>
|
horizontal
|
||||||
<ScrollView
|
showsHorizontalScrollIndicator={false}
|
||||||
horizontal
|
style={styles.scrollView}
|
||||||
showsHorizontalScrollIndicator={false}
|
contentContainerStyle={styles.scrollContent}
|
||||||
style={styles.languageScroll}
|
>
|
||||||
contentContainerStyle={styles.languageScrollContent}
|
{subtitleOptions.map((option, index) => (
|
||||||
>
|
<TVTrackCard
|
||||||
{displayLanguages.map((lang, index) => (
|
key={index}
|
||||||
<LanguageCard
|
ref={
|
||||||
key={lang.code}
|
index === initialSelectedIndex
|
||||||
code={lang.code}
|
? firstTrackCardRef
|
||||||
name={lang.name}
|
: undefined
|
||||||
selected={selectedLanguage === lang.code}
|
}
|
||||||
hasTVPreferredFocus={
|
label={option.label}
|
||||||
index === 0 &&
|
selected={option.selected}
|
||||||
(!searchResults || searchResults.length === 0)
|
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||||
}
|
onPress={() => handleTrackSelect(option.value)}
|
||||||
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)'
|
|
||||||
/>
|
/>
|
||||||
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results list */}
|
{/* Language Selector */}
|
||||||
{searchResults && searchResults.length > 0 && !isSearching && (
|
<View style={styles.section}>
|
||||||
<ScrollView
|
<Text style={styles.sectionTitle}>
|
||||||
horizontal
|
{t("player.language") || "Language"}
|
||||||
showsHorizontalScrollIndicator={false}
|
</Text>
|
||||||
style={styles.resultsScroll}
|
<ScrollView
|
||||||
contentContainerStyle={styles.resultsScrollContent}
|
horizontal
|
||||||
>
|
showsHorizontalScrollIndicator={false}
|
||||||
{searchResults.map((result, index) => (
|
style={styles.scrollView}
|
||||||
<SubtitleResultCard
|
contentContainerStyle={styles.languageScrollContent}
|
||||||
key={result.id}
|
>
|
||||||
ref={index === 0 ? firstResultRef : undefined}
|
{displayLanguages.map((lang, index) => (
|
||||||
result={result}
|
<LanguageCard
|
||||||
hasTVPreferredFocus={index === 0}
|
key={lang.code}
|
||||||
isDownloading={downloadingId === result.id}
|
code={lang.code}
|
||||||
onPress={() => handleDownload(result)}
|
name={lang.name}
|
||||||
/>
|
selected={selectedLanguage === lang.code}
|
||||||
))}
|
hasTVPreferredFocus={
|
||||||
</ScrollView>
|
index === 0 &&
|
||||||
)}
|
(!searchResults || searchResults.length === 0)
|
||||||
</View>
|
}
|
||||||
|
onPress={() => handleLanguageSelect(lang.code)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* API Key hint if no fallback available */}
|
{/* Results Section */}
|
||||||
{!hasOpenSubtitlesApiKey && (
|
<View style={styles.section}>
|
||||||
<View style={styles.apiKeyHint}>
|
<Text style={styles.sectionTitle}>
|
||||||
<Ionicons
|
{t("player.results") || "Results"}
|
||||||
name='information-circle-outline'
|
{searchResults && ` (${searchResults.length})`}
|
||||||
size={16}
|
</Text>
|
||||||
color='rgba(255,255,255,0.4)'
|
|
||||||
/>
|
{/* Loading state */}
|
||||||
<Text style={styles.apiKeyHintText}>
|
{isSearching && (
|
||||||
{t("player.add_opensubtitles_key_hint") ||
|
<View style={styles.loadingContainer}>
|
||||||
"Add OpenSubtitles API key in settings for client-side fallback"}
|
<ActivityIndicator size='large' color='#fff' />
|
||||||
</Text>
|
<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>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TVFocusGuideView>
|
</TVFocusGuideView>
|
||||||
@@ -588,7 +840,7 @@ const styles = StyleSheet.create({
|
|||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
},
|
},
|
||||||
sheetContainer: {
|
sheetContainer: {
|
||||||
maxHeight: "70%",
|
maxHeight: "75%",
|
||||||
},
|
},
|
||||||
blurContainer: {
|
blurContainer: {
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
@@ -598,23 +850,73 @@ const styles = StyleSheet.create({
|
|||||||
content: {
|
content: {
|
||||||
paddingTop: 24,
|
paddingTop: 24,
|
||||||
paddingBottom: 48,
|
paddingBottom: 48,
|
||||||
},
|
overflow: "visible",
|
||||||
header: {
|
|
||||||
paddingHorizontal: 48,
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 24,
|
fontSize: 18,
|
||||||
fontWeight: "600",
|
fontWeight: "500",
|
||||||
color: "#fff",
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
},
|
},
|
||||||
sourceHint: {
|
tabRow: {
|
||||||
fontSize: 14,
|
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)",
|
color: "rgba(255,255,255,0.5)",
|
||||||
marginTop: 4,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
marginBottom: 20,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -625,9 +927,6 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: 48,
|
||||||
},
|
},
|
||||||
languageScroll: {
|
|
||||||
overflow: "visible",
|
|
||||||
},
|
|
||||||
languageScrollContent: {
|
languageScrollContent: {
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: 48,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
@@ -649,14 +948,6 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
checkmark: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 6,
|
|
||||||
right: 6,
|
|
||||||
},
|
|
||||||
resultsScroll: {
|
|
||||||
overflow: "visible",
|
|
||||||
},
|
|
||||||
resultsScrollContent: {
|
resultsScrollContent: {
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: 48,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import TrackPlayer, {
|
import { Platform } from "react-native";
|
||||||
Event,
|
|
||||||
type PlaybackActiveTrackChangedEvent,
|
|
||||||
State,
|
|
||||||
useActiveTrack,
|
|
||||||
usePlaybackState,
|
|
||||||
useProgress,
|
|
||||||
} from "react-native-track-player";
|
|
||||||
import {
|
import {
|
||||||
audioStorageEvents,
|
audioStorageEvents,
|
||||||
deleteTrack,
|
deleteTrack,
|
||||||
@@ -14,7 +7,34 @@ import {
|
|||||||
} from "@/providers/AudioStorage";
|
} from "@/providers/AudioStorage";
|
||||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
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 { position, duration } = useProgress(1000);
|
||||||
const playbackState = usePlaybackState();
|
const playbackState = usePlaybackState();
|
||||||
const activeTrack = useActiveTrack();
|
const activeTrack = useActiveTrack();
|
||||||
@@ -48,7 +68,7 @@ export const MusicPlaybackEngine: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isPlaying = playbackState.state === State.Playing;
|
const isPlaying = playbackState.state === State.Playing;
|
||||||
setIsPlaying(isPlaying);
|
setIsPlaying(isPlaying);
|
||||||
}, [playbackState.state, setIsPlaying]);
|
}, [playbackState.state, setIsPlaying, State.Playing]);
|
||||||
|
|
||||||
// Sync active track changes
|
// Sync active track changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,59 +91,63 @@ export const MusicPlaybackEngine: React.FC = () => {
|
|||||||
// Listen for track changes (native -> JS)
|
// Listen for track changes (native -> JS)
|
||||||
// This triggers look-ahead caching, checks for cached versions, and handles track end
|
// This triggers look-ahead caching, checks for cached versions, and handles track end
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription =
|
const subscription = TrackPlayer.addEventListener(
|
||||||
TrackPlayer.addEventListener<PlaybackActiveTrackChangedEvent>(
|
Event.PlaybackActiveTrackChanged,
|
||||||
Event.PlaybackActiveTrackChanged,
|
async (event: PlaybackActiveTrackChangedEvent) => {
|
||||||
async (event) => {
|
// Trigger look-ahead caching when a new track starts playing
|
||||||
// Trigger look-ahead caching when a new track starts playing
|
if (event.track) {
|
||||||
if (event.track) {
|
triggerLookahead();
|
||||||
triggerLookahead();
|
|
||||||
|
|
||||||
// Check if there's a cached version we should use instead
|
// Check if there's a cached version we should use instead
|
||||||
const trackId = event.track.id;
|
const trackId = event.track.id;
|
||||||
const currentUrl = event.track.url as string;
|
const currentUrl = event.track.url as string;
|
||||||
|
|
||||||
// Only check if currently using a remote URL
|
// Only check if currently using a remote URL
|
||||||
if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
|
if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
|
||||||
const cachedPath = getLocalPath(trackId);
|
const cachedPath = getLocalPath(trackId);
|
||||||
if (cachedPath) {
|
if (cachedPath) {
|
||||||
console.log(
|
console.log(
|
||||||
`[AudioCache] Switching to cached version for ${trackId}`,
|
`[AudioCache] Switching to cached version for ${trackId}`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
// Load the cached version, preserving position if any
|
// Load the cached version, preserving position if any
|
||||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||||
if (currentIndex !== undefined && currentIndex >= 0) {
|
if (currentIndex !== undefined && currentIndex >= 0) {
|
||||||
const queue = await TrackPlayer.getQueue();
|
const queue = await TrackPlayer.getQueue();
|
||||||
const track = queue[currentIndex];
|
const track = queue[currentIndex];
|
||||||
// Remove and re-add with cached URL
|
// Remove and re-add with cached URL
|
||||||
await TrackPlayer.remove(currentIndex);
|
await TrackPlayer.remove(currentIndex);
|
||||||
await TrackPlayer.add(
|
await TrackPlayer.add(
|
||||||
{ ...track, url: cachedPath },
|
{ ...track, url: cachedPath },
|
||||||
currentIndex,
|
currentIndex,
|
||||||
);
|
|
||||||
await TrackPlayer.skip(currentIndex);
|
|
||||||
await TrackPlayer.play();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
"[AudioCache] Failed to switch to cached version:",
|
|
||||||
error,
|
|
||||||
);
|
);
|
||||||
|
await TrackPlayer.skip(currentIndex);
|
||||||
|
await TrackPlayer.play();
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[AudioCache] Failed to switch to cached version:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If there's no next track and the previous track ended, call onTrackEnd
|
// If there's no next track and the previous track ended, call onTrackEnd
|
||||||
if (event.lastTrack && !event.track) {
|
if (event.lastTrack && !event.track) {
|
||||||
onTrackEnd();
|
onTrackEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => subscription.remove();
|
return () => subscription.remove();
|
||||||
}, [onTrackEnd, triggerLookahead]);
|
}, [
|
||||||
|
Event.PlaybackActiveTrackChanged,
|
||||||
|
TrackPlayer,
|
||||||
|
onTrackEnd,
|
||||||
|
triggerLookahead,
|
||||||
|
]);
|
||||||
|
|
||||||
// Listen for audio cache download completion and update queue URLs
|
// Listen for audio cache download completion and update queue URLs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,7 +165,7 @@ export const MusicPlaybackEngine: React.FC = () => {
|
|||||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||||
|
|
||||||
// Find the track in the queue
|
// Find the track in the queue
|
||||||
const trackIndex = queue.findIndex((t) => t.id === itemId);
|
const trackIndex = queue.findIndex((t: Track) => t.id === itemId);
|
||||||
|
|
||||||
// Only update if track is in queue and not currently playing
|
// Only update if track is in queue and not currently playing
|
||||||
if (trackIndex >= 0 && trackIndex !== currentIndex) {
|
if (trackIndex >= 0 && trackIndex !== currentIndex) {
|
||||||
@@ -170,13 +194,13 @@ export const MusicPlaybackEngine: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
audioStorageEvents.off("complete", onComplete);
|
audioStorageEvents.off("complete", onComplete);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [TrackPlayer]);
|
||||||
|
|
||||||
// Listen for playback errors (corrupted cache files)
|
// Listen for playback errors (corrupted cache files)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = TrackPlayer.addEventListener(
|
const subscription = TrackPlayer.addEventListener(
|
||||||
Event.PlaybackError,
|
Event.PlaybackError,
|
||||||
async (event) => {
|
async (event: PlaybackErrorEvent) => {
|
||||||
const activeTrack = await TrackPlayer.getActiveTrack();
|
const activeTrack = await TrackPlayer.getActiveTrack();
|
||||||
if (!activeTrack?.url) return;
|
if (!activeTrack?.url) return;
|
||||||
|
|
||||||
@@ -215,8 +239,14 @@ export const MusicPlaybackEngine: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => subscription.remove();
|
return () => subscription.remove();
|
||||||
}, []);
|
}, [Event.PlaybackError, TrackPlayer]);
|
||||||
|
|
||||||
// No visual component needed - TrackPlayer is headless
|
// No visual component needed - TrackPlayer is headless
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export the appropriate component based on platform and module availability
|
||||||
|
export const MusicPlaybackEngine: React.FC =
|
||||||
|
Platform.isTV || !TrackPlayerModule
|
||||||
|
? StubMusicPlaybackEngine
|
||||||
|
: MobileMusicPlaybackEngine;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVSubtitleSheet } from "@/components/common/TVSubtitleSheet";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
@@ -52,7 +53,6 @@ import { CONTROLS_CONSTANTS } from "./constants";
|
|||||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||||
import { useVideoTime } from "./hooks/useVideoTime";
|
import { useVideoTime } from "./hooks/useVideoTime";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
import { TVSubtitleSearch } from "./TVSubtitleSearch";
|
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -867,7 +867,7 @@ export const Controls: FC<Props> = ({
|
|||||||
const nextItem = nextItemProp ?? internalNextItem;
|
const nextItem = nextItemProp ?? internalNextItem;
|
||||||
|
|
||||||
// Modal state for option selectors
|
// Modal state for option selectors
|
||||||
type ModalType = "audio" | "subtitle" | "subtitleSearch" | null;
|
type ModalType = "audio" | "subtitle" | null;
|
||||||
const [openModal, setOpenModal] = useState<ModalType>(null);
|
const [openModal, setOpenModal] = useState<ModalType>(null);
|
||||||
const isModalOpen = openModal !== null;
|
const isModalOpen = openModal !== null;
|
||||||
|
|
||||||
@@ -910,36 +910,12 @@ export const Controls: FC<Props> = ({
|
|||||||
}));
|
}));
|
||||||
}, [audioTracks, audioIndex]);
|
}, [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
|
// Get display labels for buttons
|
||||||
const _selectedAudioLabel = useMemo(() => {
|
const _selectedAudioLabel = useMemo(() => {
|
||||||
const track = audioTracks.find((t) => t.Index === audioIndex);
|
const track = audioTracks.find((t) => t.Index === audioIndex);
|
||||||
return track?.DisplayTitle || track?.Language || t("item_card.audio");
|
return track?.DisplayTitle || track?.Language || t("item_card.audio");
|
||||||
}, [audioTracks, audioIndex, t]);
|
}, [audioTracks, audioIndex, t]);
|
||||||
|
|
||||||
const _selectedSubtitleLabel = useMemo(() => {
|
|
||||||
if (subtitleIndex === -1) return t("item_card.subtitles.none");
|
|
||||||
const track = subtitleTracks.find((t) => t.Index === subtitleIndex);
|
|
||||||
return (
|
|
||||||
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
|
||||||
);
|
|
||||||
}, [subtitleTracks, subtitleIndex, t]);
|
|
||||||
|
|
||||||
// Handlers for option changes
|
// Handlers for option changes
|
||||||
const handleAudioChange = useCallback(
|
const handleAudioChange = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
@@ -1074,25 +1050,6 @@ export const Controls: FC<Props> = ({
|
|||||||
controlsInteractionRef.current();
|
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)
|
// Progress value for the progress bar (directly from playback progress)
|
||||||
const effectiveProgress = useSharedValue(0);
|
const effectiveProgress = useSharedValue(0);
|
||||||
|
|
||||||
@@ -1454,26 +1411,13 @@ export const Controls: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subtitle button - only show when subtitle tracks are available */}
|
{/* Subtitle button - always show to allow search even if no tracks */}
|
||||||
{subtitleTracks.length > 0 && (
|
|
||||||
<TVControlButton
|
|
||||||
icon='text'
|
|
||||||
onPress={handleOpenSubtitleSheet}
|
|
||||||
disabled={isModalOpen}
|
|
||||||
hasTVPreferredFocus={
|
|
||||||
!isModalOpen && lastOpenedModal === "subtitle"
|
|
||||||
}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subtitle Search button */}
|
|
||||||
<TVControlButton
|
<TVControlButton
|
||||||
icon='download-outline'
|
icon='text'
|
||||||
onPress={handleOpenSubtitleSearch}
|
onPress={handleOpenSubtitleSheet}
|
||||||
disabled={isModalOpen}
|
disabled={isModalOpen}
|
||||||
hasTVPreferredFocus={
|
hasTVPreferredFocus={
|
||||||
!isModalOpen && lastOpenedModal === "subtitleSearch"
|
!isModalOpen && lastOpenedModal === "subtitle"
|
||||||
}
|
}
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
@@ -1540,22 +1484,17 @@ export const Controls: FC<Props> = ({
|
|||||||
onClose={() => setOpenModal(null)}
|
onClose={() => setOpenModal(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TVOptionSelector
|
{/* Subtitle Sheet with tabs for tracks and search */}
|
||||||
|
<TVSubtitleSheet
|
||||||
visible={openModal === "subtitle"}
|
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}
|
item={item}
|
||||||
mediaSourceId={mediaSource?.Id}
|
mediaSourceId={mediaSource?.Id}
|
||||||
|
subtitleTracks={subtitleTracks}
|
||||||
|
currentSubtitleIndex={subtitleIndex ?? -1}
|
||||||
|
onSubtitleChange={handleSubtitleChange}
|
||||||
onClose={() => setOpenModal(null)}
|
onClose={() => setOpenModal(null)}
|
||||||
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
onServerSubtitleDownloaded={onServerSubtitleDownloaded}
|
||||||
onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded}
|
onLocalSubtitleDownloaded={addSubtitleFile}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
12
index.js
12
index.js
@@ -2,9 +2,13 @@ import "react-native-url-polyfill/auto";
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import "expo-router/entry";
|
import "expo-router/entry";
|
||||||
|
|
||||||
// TrackPlayer is not supported on tvOS
|
// TrackPlayer is not supported on tvOS - wrap in try-catch in case native module isn't linked
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
const TrackPlayer = require("react-native-track-player").default;
|
try {
|
||||||
const { PlaybackService } = require("./services/PlaybackService");
|
const TrackPlayer = require("react-native-track-player").default;
|
||||||
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
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 CoreVideo
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
|
/// HDR mode detected from video properties
|
||||||
|
enum HDRMode {
|
||||||
|
case sdr
|
||||||
|
case hdr10
|
||||||
|
case dolbyVision
|
||||||
|
case hlg
|
||||||
|
}
|
||||||
|
|
||||||
protocol MPVLayerRendererDelegate: AnyObject {
|
protocol MPVLayerRendererDelegate: AnyObject {
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double)
|
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
||||||
|
func renderer(_ renderer: MPVLayerRenderer, didDetectHDRMode mode: HDRMode, fps: Double)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MPV player using vo_avfoundation for video output.
|
/// MPV player using vo_avfoundation for video output.
|
||||||
@@ -428,6 +437,9 @@ final class MPVLayerRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect HDR mode for tvOS display switching
|
||||||
|
detectHDRMode()
|
||||||
|
|
||||||
case MPV_EVENT_SEEK:
|
case MPV_EVENT_SEEK:
|
||||||
// Seek started - show loading indicator and enable immediate progress updates
|
// Seek started - show loading indicator and enable immediate progress updates
|
||||||
isSeeking = true
|
isSeeking = true
|
||||||
@@ -793,6 +805,53 @@ final class MPVLayerRenderer {
|
|||||||
return Int(aid)
|
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
|
// MARK: - Technical Info
|
||||||
|
|
||||||
func getTechnicalInfo() -> [String: Any] {
|
func getTechnicalInfo() -> [String: Any] {
|
||||||
|
|||||||
@@ -308,6 +308,9 @@ class MpvPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
#if os(tvOS)
|
||||||
|
resetDisplayCriteria()
|
||||||
|
#endif
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
displayLayer.removeFromSuperlayer()
|
displayLayer.removeFromSuperlayer()
|
||||||
@@ -376,8 +379,63 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
self.onTracksReady([:])
|
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
|
// MARK: - PiPControllerDelegate
|
||||||
|
|
||||||
extension MpvPlayerView: PiPControllerDelegate {
|
extension MpvPlayerView: PiPControllerDelegate {
|
||||||
|
|||||||
@@ -30,20 +30,32 @@ import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
|||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
// Conditionally import TrackPlayer only on non-TV platforms
|
// Conditionally import TrackPlayer only on non-TV platforms
|
||||||
// This prevents the native module from being loaded on TV where it doesn't exist
|
// Wrap in try-catch in case native module isn't linked
|
||||||
const TrackPlayer = Platform.isTV
|
let TrackPlayerModule: typeof import("react-native-track-player") | null = null;
|
||||||
? null
|
if (!Platform.isTV) {
|
||||||
: require("react-native-track-player").default;
|
try {
|
||||||
|
TrackPlayerModule = require("react-native-track-player");
|
||||||
const TrackPlayerModule = Platform.isTV
|
} catch (e) {
|
||||||
? null
|
console.warn("TrackPlayer not available:", e);
|
||||||
: require("react-native-track-player");
|
}
|
||||||
|
}
|
||||||
|
const TrackPlayer = TrackPlayerModule?.default ?? null;
|
||||||
|
|
||||||
// Extract types and enums from the module (only available on non-TV)
|
// Extract types and enums from the module (only available on non-TV)
|
||||||
const Capability = TrackPlayerModule?.Capability;
|
const Capability = TrackPlayerModule?.Capability;
|
||||||
const TPRepeatMode = TrackPlayerModule?.RepeatMode;
|
const TPRepeatMode = TrackPlayerModule?.RepeatMode;
|
||||||
type Track = NonNullable<typeof TrackPlayerModule>["Track"];
|
|
||||||
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
|
// Storage keys
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
@@ -382,7 +394,7 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
|
|
||||||
// Setup TrackPlayer and AudioStorage
|
// Setup TrackPlayer and AudioStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!TrackPlayer) return;
|
if (!TrackPlayer || !Capability) return;
|
||||||
|
|
||||||
const setupPlayer = async () => {
|
const setupPlayer = async () => {
|
||||||
if (playerSetupRef.current) return;
|
if (playerSetupRef.current) return;
|
||||||
@@ -432,21 +444,21 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
|
|
||||||
// Sync repeat mode to TrackPlayer
|
// Sync repeat mode to TrackPlayer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!TrackPlayer) return;
|
if (!TrackPlayer || !TPRepeatMode) return;
|
||||||
|
|
||||||
const syncRepeatMode = async () => {
|
const syncRepeatMode = async () => {
|
||||||
if (!playerSetupRef.current) return;
|
if (!playerSetupRef.current) return;
|
||||||
|
|
||||||
let tpRepeatMode: typeof TPRepeatMode;
|
let tpRepeatMode: number;
|
||||||
switch (state.repeatMode) {
|
switch (state.repeatMode) {
|
||||||
case "one":
|
case "one":
|
||||||
tpRepeatMode = TPRepeatMode?.Track;
|
tpRepeatMode = TPRepeatMode.Track;
|
||||||
break;
|
break;
|
||||||
case "all":
|
case "all":
|
||||||
tpRepeatMode = TPRepeatMode?.Queue;
|
tpRepeatMode = TPRepeatMode.Queue;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
tpRepeatMode = TPRepeatMode?.Off;
|
tpRepeatMode = TPRepeatMode.Off;
|
||||||
}
|
}
|
||||||
await TrackPlayer.setRepeatMode(tpRepeatMode);
|
await TrackPlayer.setRepeatMode(tpRepeatMode);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 () => {
|
export const PlaybackService = async () => {
|
||||||
|
// TrackPlayer is not supported on tvOS
|
||||||
|
if (Platform.isTV || !TrackPlayer || !Event) return;
|
||||||
|
|
||||||
TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
|
TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
|
||||||
|
|
||||||
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
|
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
|
||||||
@@ -13,8 +29,9 @@ export const PlaybackService = async () => {
|
|||||||
TrackPlayer.skipToPrevious(),
|
TrackPlayer.skipToPrevious(),
|
||||||
);
|
);
|
||||||
|
|
||||||
TrackPlayer.addEventListener(Event.RemoteSeek, (event) =>
|
TrackPlayer.addEventListener(
|
||||||
TrackPlayer.seekTo(event.position),
|
Event.RemoteSeek,
|
||||||
|
(event: { position: number }) => TrackPlayer.seekTo(event.position),
|
||||||
);
|
);
|
||||||
|
|
||||||
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.reset());
|
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.reset());
|
||||||
|
|||||||
@@ -260,7 +260,12 @@
|
|||||||
"subtitle_font": "Subtitle Font",
|
"subtitle_font": "Subtitle Font",
|
||||||
"ksplayer_title": "KSPlayer Settings",
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
"hardware_decode": "Hardware Decoding",
|
"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": {
|
"vlc_subtitles": {
|
||||||
"title": "VLC Subtitle Settings",
|
"title": "VLC Subtitle Settings",
|
||||||
@@ -619,7 +624,19 @@
|
|||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel",
|
"downloaded_file_cancel": "Cancel",
|
||||||
"swipe_down_settings": "Swipe down for settings",
|
"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": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
|
|||||||
Reference in New Issue
Block a user