mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-18 17:18:11 +00:00
feat: open subtitles
This commit is contained in:
@@ -822,6 +822,21 @@ 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,7 +6,6 @@ import {
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getMediaInfoApi,
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
@@ -935,47 +934,16 @@ export default function page() {
|
||||
}, []);
|
||||
|
||||
// TV: Handle server-side subtitle download (needs media source refresh)
|
||||
// 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]);
|
||||
// Note: After downloading via Jellyfin API, the subtitle appears in the track list
|
||||
// but we need to re-fetch the media source to see it. For now, we just log a message.
|
||||
// A full implementation would refetch getStreamUrl and update the stream state.
|
||||
const handleServerSubtitleDownloaded = useCallback(() => {
|
||||
console.log(
|
||||
"Server-side subtitle downloaded - track list should be refreshed",
|
||||
);
|
||||
// TODO: Implement media source refresh to pick up new subtitle
|
||||
// This would involve re-calling getStreamUrl and updating the stream state
|
||||
}, []);
|
||||
|
||||
// TV: Navigate to next item
|
||||
const goToNextItem = useCallback(() => {
|
||||
|
||||
@@ -32,8 +32,8 @@ import { Badge } from "@/components/Badge";
|
||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVSubtitleSheet } from "@/components/common/TVSubtitleSheet";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import { TVSubtitleSheet } from "@/components/video-player/controls/TVSubtitleSheet";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
@@ -1037,13 +1037,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined));
|
||||
}, []);
|
||||
|
||||
// Refresh item data when server-side subtitle is downloaded
|
||||
// Handle server-side subtitle download - invalidate queries to refresh tracks
|
||||
const handleServerSubtitleDownloaded = useCallback(() => {
|
||||
// Invalidate item queries to refresh media sources with new subtitle
|
||||
if (item?.Id) {
|
||||
queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
|
||||
}
|
||||
}, [queryClient, item?.Id]);
|
||||
}, [item?.Id, queryClient]);
|
||||
|
||||
// Get display values for buttons
|
||||
const selectedAudioLabel = useMemo(() => {
|
||||
@@ -1115,11 +1114,23 @@ export const ItemContentTV: React.FC<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(() => {
|
||||
// Subtitle is always the last button since it's always shown
|
||||
return "subtitle";
|
||||
}, []);
|
||||
const hasSubtitleOption =
|
||||
subtitleTracks.length > 0 ||
|
||||
selectedOptions?.subtitleIndex !== undefined;
|
||||
const hasAudioOption = audioTracks.length > 0;
|
||||
const hasMediaSourceOption = mediaSources.length > 1;
|
||||
|
||||
if (hasSubtitleOption) return "subtitle";
|
||||
if (hasAudioOption) return "audio";
|
||||
if (hasMediaSourceOption) return "mediaSource";
|
||||
return "quality";
|
||||
}, [
|
||||
subtitleTracks.length,
|
||||
selectedOptions?.subtitleIndex,
|
||||
audioTracks.length,
|
||||
mediaSources.length,
|
||||
]);
|
||||
|
||||
if (!item || !selectedOptions) return null;
|
||||
|
||||
@@ -1408,17 +1419,20 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Subtitle selector - always show to enable search */}
|
||||
<TVOptionButton
|
||||
ref={
|
||||
lastOptionButton === "subtitle"
|
||||
? setLastOptionButtonRef
|
||||
: undefined
|
||||
}
|
||||
label={t("item_card.subtitles.label")}
|
||||
value={selectedSubtitleLabel}
|
||||
onPress={() => setOpenModal("subtitle")}
|
||||
/>
|
||||
{/* 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")}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Focus guide to direct navigation from options to cast list */}
|
||||
@@ -1721,17 +1735,19 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
{/* Subtitle Sheet with tabs for tracks and search */}
|
||||
<TVSubtitleSheet
|
||||
visible={openModal === "subtitle"}
|
||||
item={item}
|
||||
mediaSourceId={selectedOptions?.mediaSource?.Id}
|
||||
subtitleTracks={subtitleTracks}
|
||||
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
|
||||
onSubtitleChange={handleSubtitleChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
||||
/>
|
||||
{/* Unified Subtitle Sheet (tracks + download) */}
|
||||
{item && (
|
||||
<TVSubtitleSheet
|
||||
visible={openModal === "subtitle"}
|
||||
item={item}
|
||||
mediaSourceId={selectedOptions?.mediaSource?.Id}
|
||||
subtitleTracks={subtitleTracks}
|
||||
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
|
||||
onSubtitleIndexChange={handleSubtitleChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import TrackPlayer, {
|
||||
Event,
|
||||
type PlaybackActiveTrackChangedEvent,
|
||||
State,
|
||||
useActiveTrack,
|
||||
usePlaybackState,
|
||||
useProgress,
|
||||
} from "react-native-track-player";
|
||||
import {
|
||||
audioStorageEvents,
|
||||
deleteTrack,
|
||||
@@ -7,34 +14,7 @@ import {
|
||||
} from "@/providers/AudioStorage";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
|
||||
// TrackPlayer is not available on tvOS - wrap in try-catch in case native module isn't linked
|
||||
let TrackPlayerModule: typeof import("react-native-track-player") | null = null;
|
||||
if (!Platform.isTV) {
|
||||
try {
|
||||
TrackPlayerModule = require("react-native-track-player");
|
||||
} catch (e) {
|
||||
console.warn("TrackPlayer not available:", e);
|
||||
}
|
||||
}
|
||||
|
||||
type PlaybackActiveTrackChangedEvent = {
|
||||
track?: { id?: string; url?: string };
|
||||
lastTrack?: { id?: string };
|
||||
};
|
||||
|
||||
type Track = { id?: string; url?: string; [key: string]: unknown };
|
||||
type PlaybackErrorEvent = { code?: string; message?: string };
|
||||
|
||||
// Stub component for tvOS or when TrackPlayer is not available
|
||||
const StubMusicPlaybackEngine: React.FC = () => null;
|
||||
|
||||
// Full implementation for non-TV platforms when TrackPlayer is available
|
||||
const MobileMusicPlaybackEngine: React.FC = () => {
|
||||
// These are guaranteed to exist since we only use this component when TrackPlayerModule is available
|
||||
const TrackPlayer = TrackPlayerModule!.default;
|
||||
const { Event, State, useActiveTrack, usePlaybackState, useProgress } =
|
||||
TrackPlayerModule!;
|
||||
|
||||
export const MusicPlaybackEngine: React.FC = () => {
|
||||
const { position, duration } = useProgress(1000);
|
||||
const playbackState = usePlaybackState();
|
||||
const activeTrack = useActiveTrack();
|
||||
@@ -68,7 +48,7 @@ const MobileMusicPlaybackEngine: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const isPlaying = playbackState.state === State.Playing;
|
||||
setIsPlaying(isPlaying);
|
||||
}, [playbackState.state, setIsPlaying, State.Playing]);
|
||||
}, [playbackState.state, setIsPlaying]);
|
||||
|
||||
// Sync active track changes
|
||||
useEffect(() => {
|
||||
@@ -91,63 +71,59 @@ const MobileMusicPlaybackEngine: React.FC = () => {
|
||||
// Listen for track changes (native -> JS)
|
||||
// This triggers look-ahead caching, checks for cached versions, and handles track end
|
||||
useEffect(() => {
|
||||
const subscription = TrackPlayer.addEventListener(
|
||||
Event.PlaybackActiveTrackChanged,
|
||||
async (event: PlaybackActiveTrackChangedEvent) => {
|
||||
// Trigger look-ahead caching when a new track starts playing
|
||||
if (event.track) {
|
||||
triggerLookahead();
|
||||
const subscription =
|
||||
TrackPlayer.addEventListener<PlaybackActiveTrackChangedEvent>(
|
||||
Event.PlaybackActiveTrackChanged,
|
||||
async (event) => {
|
||||
// Trigger look-ahead caching when a new track starts playing
|
||||
if (event.track) {
|
||||
triggerLookahead();
|
||||
|
||||
// Check if there's a cached version we should use instead
|
||||
const trackId = event.track.id;
|
||||
const currentUrl = event.track.url as string;
|
||||
// Check if there's a cached version we should use instead
|
||||
const trackId = event.track.id;
|
||||
const currentUrl = event.track.url as string;
|
||||
|
||||
// Only check if currently using a remote URL
|
||||
if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
|
||||
const cachedPath = getLocalPath(trackId);
|
||||
if (cachedPath) {
|
||||
console.log(
|
||||
`[AudioCache] Switching to cached version for ${trackId}`,
|
||||
);
|
||||
try {
|
||||
// Load the cached version, preserving position if any
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
if (currentIndex !== undefined && currentIndex >= 0) {
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
const track = queue[currentIndex];
|
||||
// Remove and re-add with cached URL
|
||||
await TrackPlayer.remove(currentIndex);
|
||||
await TrackPlayer.add(
|
||||
{ ...track, url: cachedPath },
|
||||
currentIndex,
|
||||
);
|
||||
await TrackPlayer.skip(currentIndex);
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[AudioCache] Failed to switch to cached version:",
|
||||
error,
|
||||
// Only check if currently using a remote URL
|
||||
if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
|
||||
const cachedPath = getLocalPath(trackId);
|
||||
if (cachedPath) {
|
||||
console.log(
|
||||
`[AudioCache] Switching to cached version for ${trackId}`,
|
||||
);
|
||||
try {
|
||||
// Load the cached version, preserving position if any
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
if (currentIndex !== undefined && currentIndex >= 0) {
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
const track = queue[currentIndex];
|
||||
// Remove and re-add with cached URL
|
||||
await TrackPlayer.remove(currentIndex);
|
||||
await TrackPlayer.add(
|
||||
{ ...track, url: cachedPath },
|
||||
currentIndex,
|
||||
);
|
||||
await TrackPlayer.skip(currentIndex);
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[AudioCache] Failed to switch to cached version:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no next track and the previous track ended, call onTrackEnd
|
||||
if (event.lastTrack && !event.track) {
|
||||
onTrackEnd();
|
||||
}
|
||||
},
|
||||
);
|
||||
// If there's no next track and the previous track ended, call onTrackEnd
|
||||
if (event.lastTrack && !event.track) {
|
||||
onTrackEnd();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [
|
||||
Event.PlaybackActiveTrackChanged,
|
||||
TrackPlayer,
|
||||
onTrackEnd,
|
||||
triggerLookahead,
|
||||
]);
|
||||
}, [onTrackEnd, triggerLookahead]);
|
||||
|
||||
// Listen for audio cache download completion and update queue URLs
|
||||
useEffect(() => {
|
||||
@@ -165,7 +141,7 @@ const MobileMusicPlaybackEngine: React.FC = () => {
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
|
||||
// Find the track in the queue
|
||||
const trackIndex = queue.findIndex((t: Track) => t.id === itemId);
|
||||
const trackIndex = queue.findIndex((t) => t.id === itemId);
|
||||
|
||||
// Only update if track is in queue and not currently playing
|
||||
if (trackIndex >= 0 && trackIndex !== currentIndex) {
|
||||
@@ -194,13 +170,13 @@ const MobileMusicPlaybackEngine: React.FC = () => {
|
||||
return () => {
|
||||
audioStorageEvents.off("complete", onComplete);
|
||||
};
|
||||
}, [TrackPlayer]);
|
||||
}, []);
|
||||
|
||||
// Listen for playback errors (corrupted cache files)
|
||||
useEffect(() => {
|
||||
const subscription = TrackPlayer.addEventListener(
|
||||
Event.PlaybackError,
|
||||
async (event: PlaybackErrorEvent) => {
|
||||
async (event) => {
|
||||
const activeTrack = await TrackPlayer.getActiveTrack();
|
||||
if (!activeTrack?.url) return;
|
||||
|
||||
@@ -239,14 +215,8 @@ const MobileMusicPlaybackEngine: React.FC = () => {
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [Event.PlaybackError, TrackPlayer]);
|
||||
}, []);
|
||||
|
||||
// No visual component needed - TrackPlayer is headless
|
||||
return null;
|
||||
};
|
||||
|
||||
// Export the appropriate component based on platform and module availability
|
||||
export const MusicPlaybackEngine: React.FC =
|
||||
Platform.isTV || !TrackPlayerModule
|
||||
? StubMusicPlaybackEngine
|
||||
: MobileMusicPlaybackEngine;
|
||||
|
||||
@@ -40,7 +40,6 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVSubtitleSheet } from "@/components/common/TVSubtitleSheet";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
@@ -53,6 +52,7 @@ import { CONTROLS_CONSTANTS } from "./constants";
|
||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
import { TVSubtitleSheet } from "./TVSubtitleSheet";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
|
||||
interface Props {
|
||||
@@ -916,6 +916,14 @@ export const Controls: FC<Props> = ({
|
||||
return track?.DisplayTitle || track?.Language || t("item_card.audio");
|
||||
}, [audioTracks, audioIndex, t]);
|
||||
|
||||
const _selectedSubtitleLabel = useMemo(() => {
|
||||
if (subtitleIndex === -1) return t("item_card.subtitles.none");
|
||||
const track = subtitleTracks.find((t) => t.Index === subtitleIndex);
|
||||
return (
|
||||
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
||||
);
|
||||
}, [subtitleTracks, subtitleIndex, t]);
|
||||
|
||||
// Handlers for option changes
|
||||
const handleAudioChange = useCallback(
|
||||
(index: number) => {
|
||||
@@ -1050,6 +1058,19 @@ export const Controls: FC<Props> = ({
|
||||
controlsInteractionRef.current();
|
||||
}, []);
|
||||
|
||||
// Handler for when a subtitle is downloaded via server
|
||||
const handleServerSubtitleDownloaded = useCallback(() => {
|
||||
onServerSubtitleDownloaded?.();
|
||||
}, [onServerSubtitleDownloaded]);
|
||||
|
||||
// Handler for when a subtitle is downloaded locally
|
||||
const handleLocalSubtitleDownloaded = useCallback(
|
||||
(path: string) => {
|
||||
addSubtitleFile?.(path);
|
||||
},
|
||||
[addSubtitleFile],
|
||||
);
|
||||
|
||||
// Progress value for the progress bar (directly from playback progress)
|
||||
const effectiveProgress = useSharedValue(0);
|
||||
|
||||
@@ -1411,7 +1432,7 @@ export const Controls: FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Subtitle button - always show to allow search even if no tracks */}
|
||||
{/* Subtitle button */}
|
||||
<TVControlButton
|
||||
icon='text'
|
||||
onPress={handleOpenSubtitleSheet}
|
||||
@@ -1484,17 +1505,17 @@ export const Controls: FC<Props> = ({
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
{/* Subtitle Sheet with tabs for tracks and search */}
|
||||
{/* Unified Subtitle Sheet (tracks + download) */}
|
||||
<TVSubtitleSheet
|
||||
visible={openModal === "subtitle"}
|
||||
item={item}
|
||||
mediaSourceId={mediaSource?.Id}
|
||||
subtitleTracks={subtitleTracks}
|
||||
currentSubtitleIndex={subtitleIndex ?? -1}
|
||||
onSubtitleChange={handleSubtitleChange}
|
||||
onSubtitleIndexChange={handleSubtitleChange}
|
||||
onClose={() => setOpenModal(null)}
|
||||
onServerSubtitleDownloaded={onServerSubtitleDownloaded}
|
||||
onLocalSubtitleDownloaded={addSubtitleFile}
|
||||
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
||||
onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -29,29 +29,34 @@ import {
|
||||
} from "@/hooks/useRemoteSubtitles";
|
||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||
|
||||
interface Props {
|
||||
interface TVSubtitleSheetProps {
|
||||
visible: boolean;
|
||||
item: BaseItemDto;
|
||||
mediaSourceId?: string | null;
|
||||
|
||||
// Existing subtitle tracks from media source
|
||||
subtitleTracks: MediaStream[];
|
||||
currentSubtitleIndex: number;
|
||||
onSubtitleChange: (index: number) => void;
|
||||
|
||||
// Track selection callback
|
||||
onSubtitleIndexChange: (index: number) => void;
|
||||
onClose: () => void;
|
||||
/** Called when a subtitle is downloaded locally (client-side) - only during playback */
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
/** Called when a subtitle is downloaded via Jellyfin API (server-side) */
|
||||
|
||||
// Optional - for during-playback context only
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
}
|
||||
|
||||
type TabType = "tracks" | "search";
|
||||
type TabType = "tracks" | "download";
|
||||
|
||||
// Tab button component
|
||||
const TVSubtitleTab: React.FC<{
|
||||
// Tab button component - requires press to switch
|
||||
const TVTabButton: React.FC<{
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}> = ({ label, isActive, onSelect, hasTVPreferredFocus }) => {
|
||||
disabled?: boolean;
|
||||
}> = ({ label, active, onSelect, hasTVPreferredFocus, disabled }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
@@ -65,16 +70,18 @@ const TVSubtitleTab: React.FC<{
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onSelect}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
onSelect();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
@@ -83,10 +90,10 @@ const TVSubtitleTab: React.FC<{
|
||||
transform: [{ scale }],
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: isActive
|
||||
: active
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "transparent",
|
||||
borderBottomColor: isActive ? "#fff" : "transparent",
|
||||
borderBottomColor: active ? "#fff" : "transparent",
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -94,7 +101,7 @@ const TVSubtitleTab: React.FC<{
|
||||
style={[
|
||||
styles.tabText,
|
||||
{ color: focused ? "#000" : "#fff" },
|
||||
(focused || isActive) && { fontWeight: "600" },
|
||||
(focused || active) && { fontWeight: "600" },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
@@ -104,16 +111,17 @@ const TVSubtitleTab: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
// Track option card
|
||||
// Track card for subtitle track selection
|
||||
const TVTrackCard = React.forwardRef<
|
||||
View,
|
||||
{
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||
>(({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||
|
||||
@@ -162,6 +170,17 @@ const TVTrackCard = React.forwardRef<
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{sublabel && (
|
||||
<Text
|
||||
style={[
|
||||
styles.trackCardSublabel,
|
||||
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{sublabel}
|
||||
</Text>
|
||||
)}
|
||||
{selected && !focused && (
|
||||
<View style={styles.checkmark}>
|
||||
<Ionicons
|
||||
@@ -305,7 +324,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Provider badge */}
|
||||
{/* Provider/Source badge */}
|
||||
<View
|
||||
style={[
|
||||
styles.providerBadge,
|
||||
@@ -336,6 +355,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
|
||||
{/* Meta info row */}
|
||||
<View style={styles.resultMeta}>
|
||||
{/* Format */}
|
||||
<Text
|
||||
style={[
|
||||
styles.resultMetaText,
|
||||
@@ -345,6 +365,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
{result.format?.toUpperCase()}
|
||||
</Text>
|
||||
|
||||
{/* Rating if available */}
|
||||
{result.communityRating !== undefined &&
|
||||
result.communityRating > 0 && (
|
||||
<View style={styles.ratingContainer}>
|
||||
@@ -368,6 +389,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Download count if available */}
|
||||
{result.downloadCount !== undefined && result.downloadCount > 0 && (
|
||||
<View style={styles.downloadCountContainer}>
|
||||
<Ionicons
|
||||
@@ -441,6 +463,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Loading indicator when downloading */}
|
||||
{isDownloading && (
|
||||
<View style={styles.downloadingOverlay}>
|
||||
<ActivityIndicator size='small' color='#fff' />
|
||||
@@ -451,23 +474,25 @@ const SubtitleResultCard = React.forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
visible,
|
||||
item,
|
||||
mediaSourceId,
|
||||
subtitleTracks,
|
||||
currentSubtitleIndex,
|
||||
onSubtitleChange,
|
||||
onSubtitleIndexChange,
|
||||
onClose,
|
||||
onLocalSubtitleDownloaded,
|
||||
onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded,
|
||||
}) => {
|
||||
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 firstTrackCardRef = useRef<View>(null);
|
||||
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isTabContentReady, setIsTabContentReady] = useState(false);
|
||||
const firstTrackRef = useRef<View>(null);
|
||||
|
||||
const {
|
||||
hasOpenSubtitlesApiKey,
|
||||
@@ -483,44 +508,28 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
mediaSourceId,
|
||||
});
|
||||
|
||||
// Store reset in a ref to avoid dependency issues
|
||||
const resetRef = useRef(reset);
|
||||
resetRef.current = reset;
|
||||
|
||||
// Animation values
|
||||
const overlayOpacity = useRef(new RNAnimated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new RNAnimated.Value(300)).current;
|
||||
|
||||
// Build subtitle options (with "None" option)
|
||||
const subtitleOptions = useMemo(() => {
|
||||
const noneOption = {
|
||||
label: t("item_card.subtitles.none"),
|
||||
value: -1,
|
||||
selected: currentSubtitleIndex === -1,
|
||||
};
|
||||
const trackOptions = subtitleTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||
value: track.Index!,
|
||||
selected: track.Index === currentSubtitleIndex,
|
||||
}));
|
||||
return [noneOption, ...trackOptions];
|
||||
}, [subtitleTracks, currentSubtitleIndex, t]);
|
||||
|
||||
// Find initial selected index for focus
|
||||
const initialSelectedIndex = useMemo(() => {
|
||||
const idx = subtitleOptions.findIndex((o) => o.selected);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [subtitleOptions]);
|
||||
|
||||
// Languages for search
|
||||
const displayLanguages = useMemo(
|
||||
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
||||
[],
|
||||
);
|
||||
// Determine initial selected track index
|
||||
const initialSelectedTrackIndex = useMemo(() => {
|
||||
if (currentSubtitleIndex === -1) return 0; // "None" option
|
||||
const trackIdx = subtitleTracks.findIndex(
|
||||
(t) => t.Index === currentSubtitleIndex,
|
||||
);
|
||||
return trackIdx >= 0 ? trackIdx + 1 : 0; // +1 because "None" is at index 0
|
||||
}, [subtitleTracks, currentSubtitleIndex]);
|
||||
|
||||
// Animate in/out
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(300);
|
||||
setActiveTab("tracks");
|
||||
|
||||
RNAnimated.parallel([
|
||||
RNAnimated.timing(overlayOpacity, {
|
||||
@@ -536,10 +545,18 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY, reset]);
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
// Reset state when sheet closes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setHasSearchedThisSession(false);
|
||||
setActiveTab("tracks");
|
||||
resetRef.current();
|
||||
setIsReady(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Delay rendering to work around hasTVPreferredFocus timing issue
|
||||
useEffect(() => {
|
||||
@@ -550,22 +567,23 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
// Programmatic focus fallback
|
||||
// Lazy loading: search when Download tab is first activated
|
||||
useEffect(() => {
|
||||
if (isReady && firstTrackCardRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
(firstTrackCardRef.current as any)?.requestTVFocus?.();
|
||||
}, 50);
|
||||
if (visible && activeTab === "download" && !hasSearchedThisSession) {
|
||||
search({ language: selectedLanguage });
|
||||
setHasSearchedThisSession(true);
|
||||
}
|
||||
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
|
||||
|
||||
// Delay tab content rendering to prevent focus conflicts when switching tabs
|
||||
useEffect(() => {
|
||||
if (isReady) {
|
||||
setIsTabContentReady(false);
|
||||
const timer = setTimeout(() => setIsTabContentReady(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
// Auto-search when switching to search tab
|
||||
useEffect(() => {
|
||||
if (activeTab === "search" && !searchResults && !isSearching) {
|
||||
search({ language: selectedLanguage });
|
||||
}
|
||||
}, [activeTab, searchResults, isSearching, search, selectedLanguage]);
|
||||
setIsTabContentReady(false);
|
||||
}, [activeTab, isReady]);
|
||||
|
||||
// Handle language selection
|
||||
const handleLanguageSelect = useCallback(
|
||||
@@ -579,10 +597,10 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
// Handle track selection
|
||||
const handleTrackSelect = useCallback(
|
||||
(index: number) => {
|
||||
onSubtitleChange(index);
|
||||
onSubtitleIndexChange(index);
|
||||
onClose();
|
||||
},
|
||||
[onSubtitleChange, onClose],
|
||||
[onSubtitleIndexChange, onClose],
|
||||
);
|
||||
|
||||
// Handle subtitle download
|
||||
@@ -614,8 +632,29 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
],
|
||||
);
|
||||
|
||||
// Whether we're in player context (can use local subtitles)
|
||||
const isInPlayer = Boolean(onLocalSubtitleDownloaded);
|
||||
// Subset of common languages for TV
|
||||
const displayLanguages = useMemo(
|
||||
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
||||
[],
|
||||
);
|
||||
|
||||
// Track options with "None" at the start
|
||||
const trackOptions = useMemo(() => {
|
||||
const noneOption = {
|
||||
label: t("item_card.subtitles.none"),
|
||||
sublabel: undefined as string | undefined,
|
||||
value: -1,
|
||||
selected: currentSubtitleIndex === -1,
|
||||
};
|
||||
const options = subtitleTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||
sublabel: track.Codec?.toUpperCase(),
|
||||
value: track.Index!,
|
||||
selected: track.Index === currentSubtitleIndex,
|
||||
}));
|
||||
return [noneOption, ...options];
|
||||
}, [subtitleTracks, currentSubtitleIndex, t]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
@@ -629,53 +668,54 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
>
|
||||
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
autoFocus
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Header with title */}
|
||||
<Text style={styles.title}>
|
||||
{t("item_card.subtitles.label").toUpperCase()}
|
||||
</Text>
|
||||
{/* Header with tabs */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>
|
||||
{t("item_card.subtitles.label") || "Subtitles"}
|
||||
</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")}
|
||||
/>
|
||||
{/* Tab bar */}
|
||||
<View style={styles.tabRow}>
|
||||
<TVTabButton
|
||||
label={t("item_card.subtitles.tracks") || "Tracks"}
|
||||
active={activeTab === "tracks"}
|
||||
onSelect={() => setActiveTab("tracks")}
|
||||
/>
|
||||
<TVTabButton
|
||||
label={t("player.download") || "Download"}
|
||||
active={activeTab === "download"}
|
||||
onSelect={() => setActiveTab("download")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === "tracks" && isReady && (
|
||||
<View style={styles.tabContent}>
|
||||
{/* Tracks Tab Content */}
|
||||
{activeTab === "tracks" && isTabContentReady && (
|
||||
<View style={styles.section}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
style={styles.tracksScroll}
|
||||
contentContainerStyle={styles.tracksScrollContent}
|
||||
>
|
||||
{subtitleOptions.map((option, index) => (
|
||||
{trackOptions.map((option, index) => (
|
||||
<TVTrackCard
|
||||
key={index}
|
||||
key={option.value}
|
||||
ref={
|
||||
index === initialSelectedIndex
|
||||
? firstTrackCardRef
|
||||
index === initialSelectedTrackIndex
|
||||
? firstTrackRef
|
||||
: undefined
|
||||
}
|
||||
label={option.label}
|
||||
sublabel={option.sublabel}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||
hasTVPreferredFocus={index === initialSelectedTrackIndex}
|
||||
onPress={() => handleTrackSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
@@ -683,23 +723,9 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Download Tab Content */}
|
||||
{activeTab === "download" && isTabContentReady && (
|
||||
<>
|
||||
{/* Language Selector */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
@@ -708,7 +734,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
style={styles.languageScroll}
|
||||
contentContainerStyle={styles.languageScrollContent}
|
||||
>
|
||||
{displayLanguages.map((lang, index) => (
|
||||
@@ -789,7 +815,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
style={styles.resultsScroll}
|
||||
contentContainerStyle={styles.resultsScrollContent}
|
||||
>
|
||||
{searchResults.map((result, index) => (
|
||||
@@ -805,7 +831,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* API Key hint */}
|
||||
{/* API Key hint if no fallback available */}
|
||||
{!hasOpenSubtitlesApiKey && (
|
||||
<View style={styles.apiKeyHint}>
|
||||
<Ionicons
|
||||
@@ -819,7 +845,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
@@ -840,7 +866,7 @@ const styles = StyleSheet.create({
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
maxHeight: "75%",
|
||||
maxHeight: "70%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
@@ -850,21 +876,19 @@ const styles = StyleSheet.create({
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 48,
|
||||
overflow: "visible",
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
fontSize: 24,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
tabRow: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 16,
|
||||
gap: 24,
|
||||
},
|
||||
tabButton: {
|
||||
@@ -876,15 +900,24 @@ const styles = StyleSheet.create({
|
||||
tabText: {
|
||||
fontSize: 18,
|
||||
},
|
||||
tabContent: {
|
||||
overflow: "visible",
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
scrollView: {
|
||||
overflow: "visible",
|
||||
},
|
||||
scrollContent: {
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
tracksScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
tracksScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
trackCard: {
|
||||
@@ -899,33 +932,17 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
},
|
||||
trackCardSublabel: {
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
checkmark: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
},
|
||||
downloadHint: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 16,
|
||||
},
|
||||
downloadHintText: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: 14,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 48,
|
||||
languageScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
languageScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
@@ -948,6 +965,9 @@ const styles = StyleSheet.create({
|
||||
fontSize: 11,
|
||||
marginTop: 2,
|
||||
},
|
||||
resultsScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
resultsScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
12
index.js
12
index.js
@@ -2,13 +2,9 @@ import "react-native-url-polyfill/auto";
|
||||
import { Platform } from "react-native";
|
||||
import "expo-router/entry";
|
||||
|
||||
// TrackPlayer is not supported on tvOS - wrap in try-catch in case native module isn't linked
|
||||
// TrackPlayer is not supported on tvOS
|
||||
if (!Platform.isTV) {
|
||||
try {
|
||||
const TrackPlayer = require("react-native-track-player").default;
|
||||
const { PlaybackService } = require("./services/PlaybackService");
|
||||
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
||||
} catch (e) {
|
||||
console.warn("TrackPlayer not available:", e);
|
||||
}
|
||||
const TrackPlayer = require("react-native-track-player").default;
|
||||
const { PlaybackService } = require("./services/PlaybackService");
|
||||
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
||||
}
|
||||
|
||||
@@ -30,32 +30,20 @@ import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
// Conditionally import TrackPlayer only on non-TV platforms
|
||||
// Wrap in try-catch in case native module isn't linked
|
||||
let TrackPlayerModule: typeof import("react-native-track-player") | null = null;
|
||||
if (!Platform.isTV) {
|
||||
try {
|
||||
TrackPlayerModule = require("react-native-track-player");
|
||||
} catch (e) {
|
||||
console.warn("TrackPlayer not available:", e);
|
||||
}
|
||||
}
|
||||
const TrackPlayer = TrackPlayerModule?.default ?? null;
|
||||
// This prevents the native module from being loaded on TV where it doesn't exist
|
||||
const TrackPlayer = Platform.isTV
|
||||
? null
|
||||
: require("react-native-track-player").default;
|
||||
|
||||
const TrackPlayerModule = Platform.isTV
|
||||
? null
|
||||
: require("react-native-track-player");
|
||||
|
||||
// Extract types and enums from the module (only available on non-TV)
|
||||
const Capability = TrackPlayerModule?.Capability;
|
||||
const TPRepeatMode = TrackPlayerModule?.RepeatMode;
|
||||
|
||||
// Define types locally since they can't be extracted from conditional import
|
||||
type Track = {
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
artwork?: string;
|
||||
duration?: number;
|
||||
};
|
||||
type Progress = { position: number; duration: number; buffered: number };
|
||||
type Track = NonNullable<typeof TrackPlayerModule>["Track"];
|
||||
type Progress = NonNullable<typeof TrackPlayerModule>["Progress"];
|
||||
|
||||
// Storage keys
|
||||
const STORAGE_KEYS = {
|
||||
@@ -394,7 +382,7 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
// Setup TrackPlayer and AudioStorage
|
||||
useEffect(() => {
|
||||
if (!TrackPlayer || !Capability) return;
|
||||
if (!TrackPlayer) return;
|
||||
|
||||
const setupPlayer = async () => {
|
||||
if (playerSetupRef.current) return;
|
||||
@@ -444,21 +432,21 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
// Sync repeat mode to TrackPlayer
|
||||
useEffect(() => {
|
||||
if (!TrackPlayer || !TPRepeatMode) return;
|
||||
if (!TrackPlayer) return;
|
||||
|
||||
const syncRepeatMode = async () => {
|
||||
if (!playerSetupRef.current) return;
|
||||
|
||||
let tpRepeatMode: number;
|
||||
let tpRepeatMode: typeof TPRepeatMode;
|
||||
switch (state.repeatMode) {
|
||||
case "one":
|
||||
tpRepeatMode = TPRepeatMode.Track;
|
||||
tpRepeatMode = TPRepeatMode?.Track;
|
||||
break;
|
||||
case "all":
|
||||
tpRepeatMode = TPRepeatMode.Queue;
|
||||
tpRepeatMode = TPRepeatMode?.Queue;
|
||||
break;
|
||||
default:
|
||||
tpRepeatMode = TPRepeatMode.Off;
|
||||
tpRepeatMode = TPRepeatMode?.Off;
|
||||
}
|
||||
await TrackPlayer.setRepeatMode(tpRepeatMode);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
|
||||
// TrackPlayer is not available on tvOS - wrap in try-catch in case native module isn't linked
|
||||
let TrackPlayer: typeof import("react-native-track-player").default | null =
|
||||
null;
|
||||
let Event: typeof import("react-native-track-player").Event | null = null;
|
||||
if (!Platform.isTV) {
|
||||
try {
|
||||
TrackPlayer = require("react-native-track-player").default;
|
||||
Event = require("react-native-track-player").Event;
|
||||
} catch (e) {
|
||||
console.warn("TrackPlayer not available:", e);
|
||||
}
|
||||
}
|
||||
import TrackPlayer, { Event } from "react-native-track-player";
|
||||
|
||||
export const PlaybackService = async () => {
|
||||
// TrackPlayer is not supported on tvOS
|
||||
if (Platform.isTV || !TrackPlayer || !Event) return;
|
||||
|
||||
TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
|
||||
|
||||
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
|
||||
@@ -29,9 +13,8 @@ export const PlaybackService = async () => {
|
||||
TrackPlayer.skipToPrevious(),
|
||||
);
|
||||
|
||||
TrackPlayer.addEventListener(
|
||||
Event.RemoteSeek,
|
||||
(event: { position: number }) => TrackPlayer.seekTo(event.position),
|
||||
TrackPlayer.addEventListener(Event.RemoteSeek, (event) =>
|
||||
TrackPlayer.seekTo(event.position),
|
||||
);
|
||||
|
||||
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.reset());
|
||||
|
||||
@@ -628,6 +628,7 @@
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
@@ -661,7 +662,8 @@
|
||||
"audio": "Audio",
|
||||
"subtitles": {
|
||||
"label": "Subtitle",
|
||||
"none": "None"
|
||||
"none": "None",
|
||||
"tracks": "Tracks"
|
||||
},
|
||||
"show_more": "Show More",
|
||||
"show_less": "Show Less",
|
||||
|
||||
Reference in New Issue
Block a user