mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-22 08:44:41 +01:00
feat: open subtitles
This commit is contained in:
@@ -822,6 +822,21 @@ 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,7 +6,6 @@ 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";
|
||||||
@@ -935,47 +934,16 @@ export default function page() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// TV: Handle server-side subtitle download (needs media source refresh)
|
// TV: Handle server-side subtitle download (needs media source refresh)
|
||||||
// After downloading via Jellyfin API, we need to refresh the media source
|
// Note: After downloading via Jellyfin API, the subtitle appears in the track list
|
||||||
// to see the new subtitle in the track list.
|
// but we need to re-fetch the media source to see it. For now, we just log a message.
|
||||||
const handleServerSubtitleDownloaded = useCallback(async () => {
|
// A full implementation would refetch getStreamUrl and update the stream state.
|
||||||
if (!api || !user?.Id || !item?.Id || !stream) {
|
const handleServerSubtitleDownloaded = useCallback(() => {
|
||||||
console.warn("Cannot refresh media source: missing required data");
|
console.log(
|
||||||
return;
|
"Server-side subtitle downloaded - track list should be refreshed",
|
||||||
}
|
);
|
||||||
|
// TODO: Implement media source refresh to pick up new subtitle
|
||||||
try {
|
// This would involve re-calling getStreamUrl and updating the stream state
|
||||||
// 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(() => {
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ 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 { TVSubtitleSheet } from "@/components/video-player/controls/TVSubtitleSheet";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
@@ -1037,13 +1037,12 @@ 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
|
// Handle server-side subtitle download - invalidate queries to refresh tracks
|
||||||
const handleServerSubtitleDownloaded = useCallback(() => {
|
const handleServerSubtitleDownloaded = useCallback(() => {
|
||||||
// Invalidate item queries to refresh media sources with new subtitle
|
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
|
queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
|
||||||
}
|
}
|
||||||
}, [queryClient, item?.Id]);
|
}, [item?.Id, queryClient]);
|
||||||
|
|
||||||
// Get display values for buttons
|
// Get display values for buttons
|
||||||
const selectedAudioLabel = useMemo(() => {
|
const selectedAudioLabel = useMemo(() => {
|
||||||
@@ -1115,11 +1114,23 @@ 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(() => {
|
||||||
// Subtitle is always the last button since it's always shown
|
const hasSubtitleOption =
|
||||||
return "subtitle";
|
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;
|
if (!item || !selectedOptions) return null;
|
||||||
|
|
||||||
@@ -1408,17 +1419,20 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subtitle selector - always show to enable search */}
|
{/* Subtitle selector */}
|
||||||
<TVOptionButton
|
{(subtitleTracks.length > 0 ||
|
||||||
ref={
|
selectedOptions?.subtitleIndex !== undefined) && (
|
||||||
lastOptionButton === "subtitle"
|
<TVOptionButton
|
||||||
? setLastOptionButtonRef
|
ref={
|
||||||
: undefined
|
lastOptionButton === "subtitle"
|
||||||
}
|
? setLastOptionButtonRef
|
||||||
label={t("item_card.subtitles.label")}
|
: undefined
|
||||||
value={selectedSubtitleLabel}
|
}
|
||||||
onPress={() => setOpenModal("subtitle")}
|
label={t("item_card.subtitles.label")}
|
||||||
/>
|
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 */}
|
||||||
@@ -1721,17 +1735,19 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
onClose={() => setOpenModal(null)}
|
onClose={() => setOpenModal(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Subtitle Sheet with tabs for tracks and search */}
|
{/* Unified Subtitle Sheet (tracks + download) */}
|
||||||
<TVSubtitleSheet
|
{item && (
|
||||||
visible={openModal === "subtitle"}
|
<TVSubtitleSheet
|
||||||
item={item}
|
visible={openModal === "subtitle"}
|
||||||
mediaSourceId={selectedOptions?.mediaSource?.Id}
|
item={item}
|
||||||
subtitleTracks={subtitleTracks}
|
mediaSourceId={selectedOptions?.mediaSource?.Id}
|
||||||
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
|
subtitleTracks={subtitleTracks}
|
||||||
onSubtitleChange={handleSubtitleChange}
|
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
|
||||||
onClose={() => setOpenModal(null)}
|
onSubtitleIndexChange={handleSubtitleChange}
|
||||||
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
onClose={() => setOpenModal(null)}
|
||||||
/>
|
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useRef } from "react";
|
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 {
|
import {
|
||||||
audioStorageEvents,
|
audioStorageEvents,
|
||||||
deleteTrack,
|
deleteTrack,
|
||||||
@@ -7,34 +14,7 @@ import {
|
|||||||
} from "@/providers/AudioStorage";
|
} from "@/providers/AudioStorage";
|
||||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
|
||||||
// TrackPlayer is not available on tvOS - wrap in try-catch in case native module isn't linked
|
export const MusicPlaybackEngine: React.FC = () => {
|
||||||
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();
|
||||||
@@ -68,7 +48,7 @@ const MobileMusicPlaybackEngine: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isPlaying = playbackState.state === State.Playing;
|
const isPlaying = playbackState.state === State.Playing;
|
||||||
setIsPlaying(isPlaying);
|
setIsPlaying(isPlaying);
|
||||||
}, [playbackState.state, setIsPlaying, State.Playing]);
|
}, [playbackState.state, setIsPlaying]);
|
||||||
|
|
||||||
// Sync active track changes
|
// Sync active track changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,63 +71,59 @@ const MobileMusicPlaybackEngine: 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 = TrackPlayer.addEventListener(
|
const subscription =
|
||||||
Event.PlaybackActiveTrackChanged,
|
TrackPlayer.addEventListener<PlaybackActiveTrackChangedEvent>(
|
||||||
async (event: PlaybackActiveTrackChangedEvent) => {
|
Event.PlaybackActiveTrackChanged,
|
||||||
// Trigger look-ahead caching when a new track starts playing
|
async (event) => {
|
||||||
if (event.track) {
|
// Trigger look-ahead caching when a new track starts playing
|
||||||
triggerLookahead();
|
if (event.track) {
|
||||||
|
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 {
|
|
||||||
// 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,
|
|
||||||
);
|
);
|
||||||
|
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 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(() => {
|
||||||
@@ -165,7 +141,7 @@ const MobileMusicPlaybackEngine: 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: Track) => t.id === itemId);
|
const trackIndex = queue.findIndex((t) => 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) {
|
||||||
@@ -194,13 +170,13 @@ const MobileMusicPlaybackEngine: 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: PlaybackErrorEvent) => {
|
async (event) => {
|
||||||
const activeTrack = await TrackPlayer.getActiveTrack();
|
const activeTrack = await TrackPlayer.getActiveTrack();
|
||||||
if (!activeTrack?.url) return;
|
if (!activeTrack?.url) return;
|
||||||
|
|
||||||
@@ -239,14 +215,8 @@ const MobileMusicPlaybackEngine: 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,7 +40,6 @@ 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";
|
||||||
@@ -53,6 +52,7 @@ 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 { TVSubtitleSheet } from "./TVSubtitleSheet";
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -916,6 +916,14 @@ export const Controls: FC<Props> = ({
|
|||||||
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) => {
|
||||||
@@ -1050,6 +1058,19 @@ export const Controls: FC<Props> = ({
|
|||||||
controlsInteractionRef.current();
|
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);
|
||||||
|
|
||||||
@@ -1411,7 +1432,7 @@ export const Controls: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subtitle button - always show to allow search even if no tracks */}
|
{/* Subtitle button */}
|
||||||
<TVControlButton
|
<TVControlButton
|
||||||
icon='text'
|
icon='text'
|
||||||
onPress={handleOpenSubtitleSheet}
|
onPress={handleOpenSubtitleSheet}
|
||||||
@@ -1484,17 +1505,17 @@ export const Controls: FC<Props> = ({
|
|||||||
onClose={() => setOpenModal(null)}
|
onClose={() => setOpenModal(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Subtitle Sheet with tabs for tracks and search */}
|
{/* Unified Subtitle Sheet (tracks + download) */}
|
||||||
<TVSubtitleSheet
|
<TVSubtitleSheet
|
||||||
visible={openModal === "subtitle"}
|
visible={openModal === "subtitle"}
|
||||||
item={item}
|
item={item}
|
||||||
mediaSourceId={mediaSource?.Id}
|
mediaSourceId={mediaSource?.Id}
|
||||||
subtitleTracks={subtitleTracks}
|
subtitleTracks={subtitleTracks}
|
||||||
currentSubtitleIndex={subtitleIndex ?? -1}
|
currentSubtitleIndex={subtitleIndex ?? -1}
|
||||||
onSubtitleChange={handleSubtitleChange}
|
onSubtitleIndexChange={handleSubtitleChange}
|
||||||
onClose={() => setOpenModal(null)}
|
onClose={() => setOpenModal(null)}
|
||||||
onServerSubtitleDownloaded={onServerSubtitleDownloaded}
|
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
||||||
onLocalSubtitleDownloaded={addSubtitleFile}
|
onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,29 +29,34 @@ import {
|
|||||||
} from "@/hooks/useRemoteSubtitles";
|
} from "@/hooks/useRemoteSubtitles";
|
||||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||||
|
|
||||||
interface Props {
|
interface TVSubtitleSheetProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
mediaSourceId?: string | null;
|
mediaSourceId?: string | null;
|
||||||
|
|
||||||
|
// Existing subtitle tracks from media source
|
||||||
subtitleTracks: MediaStream[];
|
subtitleTracks: MediaStream[];
|
||||||
currentSubtitleIndex: number;
|
currentSubtitleIndex: number;
|
||||||
onSubtitleChange: (index: number) => void;
|
|
||||||
|
// Track selection callback
|
||||||
|
onSubtitleIndexChange: (index: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Called when a subtitle is downloaded locally (client-side) - only during playback */
|
|
||||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
// Optional - for during-playback context only
|
||||||
/** Called when a subtitle is downloaded via Jellyfin API (server-side) */
|
|
||||||
onServerSubtitleDownloaded?: () => void;
|
onServerSubtitleDownloaded?: () => void;
|
||||||
|
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = "tracks" | "search";
|
type TabType = "tracks" | "download";
|
||||||
|
|
||||||
// Tab button component
|
// Tab button component - requires press to switch
|
||||||
const TVSubtitleTab: React.FC<{
|
const TVTabButton: React.FC<{
|
||||||
label: string;
|
label: string;
|
||||||
isActive: boolean;
|
active: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
hasTVPreferredFocus?: boolean;
|
hasTVPreferredFocus?: boolean;
|
||||||
}> = ({ label, isActive, onSelect, hasTVPreferredFocus }) => {
|
disabled?: boolean;
|
||||||
|
}> = ({ label, active, onSelect, hasTVPreferredFocus, disabled }) => {
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||||
|
|
||||||
@@ -65,16 +70,18 @@ const TVSubtitleTab: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
onPress={onSelect}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
animateTo(1.05);
|
animateTo(1.05);
|
||||||
onSelect();
|
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
animateTo(1);
|
animateTo(1);
|
||||||
}}
|
}}
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
>
|
>
|
||||||
<RNAnimated.View
|
<RNAnimated.View
|
||||||
style={[
|
style={[
|
||||||
@@ -83,10 +90,10 @@ const TVSubtitleTab: React.FC<{
|
|||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
backgroundColor: focused
|
backgroundColor: focused
|
||||||
? "#fff"
|
? "#fff"
|
||||||
: isActive
|
: active
|
||||||
? "rgba(255,255,255,0.2)"
|
? "rgba(255,255,255,0.2)"
|
||||||
: "transparent",
|
: "transparent",
|
||||||
borderBottomColor: isActive ? "#fff" : "transparent",
|
borderBottomColor: active ? "#fff" : "transparent",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -94,7 +101,7 @@ const TVSubtitleTab: React.FC<{
|
|||||||
style={[
|
style={[
|
||||||
styles.tabText,
|
styles.tabText,
|
||||||
{ color: focused ? "#000" : "#fff" },
|
{ color: focused ? "#000" : "#fff" },
|
||||||
(focused || isActive) && { fontWeight: "600" },
|
(focused || active) && { fontWeight: "600" },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -104,16 +111,17 @@ const TVSubtitleTab: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track option card
|
// Track card for subtitle track selection
|
||||||
const TVTrackCard = React.forwardRef<
|
const TVTrackCard = React.forwardRef<
|
||||||
View,
|
View,
|
||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
hasTVPreferredFocus?: boolean;
|
hasTVPreferredFocus?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
|
>(({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const scale = useRef(new RNAnimated.Value(1)).current;
|
const scale = useRef(new RNAnimated.Value(1)).current;
|
||||||
|
|
||||||
@@ -162,6 +170,17 @@ const TVTrackCard = React.forwardRef<
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</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 && (
|
{selected && !focused && (
|
||||||
<View style={styles.checkmark}>
|
<View style={styles.checkmark}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@@ -305,7 +324,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* Provider badge */}
|
{/* Provider/Source badge */}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.providerBadge,
|
styles.providerBadge,
|
||||||
@@ -336,6 +355,7 @@ 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,
|
||||||
@@ -345,6 +365,7 @@ 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}>
|
||||||
@@ -368,6 +389,7 @@ 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
|
||||||
@@ -441,6 +463,7 @@ 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' />
|
||||||
@@ -451,23 +474,25 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TVSubtitleSheet: React.FC<Props> = ({
|
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||||
visible,
|
visible,
|
||||||
item,
|
item,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
subtitleTracks,
|
subtitleTracks,
|
||||||
currentSubtitleIndex,
|
currentSubtitleIndex,
|
||||||
onSubtitleChange,
|
onSubtitleIndexChange,
|
||||||
onClose,
|
onClose,
|
||||||
onLocalSubtitleDownloaded,
|
|
||||||
onServerSubtitleDownloaded,
|
onServerSubtitleDownloaded,
|
||||||
|
onLocalSubtitleDownloaded,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
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 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 {
|
const {
|
||||||
hasOpenSubtitlesApiKey,
|
hasOpenSubtitlesApiKey,
|
||||||
@@ -483,44 +508,28 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store reset in a ref to avoid dependency issues
|
||||||
|
const resetRef = useRef(reset);
|
||||||
|
resetRef.current = reset;
|
||||||
|
|
||||||
// Animation values
|
// Animation values
|
||||||
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)
|
// Determine initial selected track index
|
||||||
const subtitleOptions = useMemo(() => {
|
const initialSelectedTrackIndex = useMemo(() => {
|
||||||
const noneOption = {
|
if (currentSubtitleIndex === -1) return 0; // "None" option
|
||||||
label: t("item_card.subtitles.none"),
|
const trackIdx = subtitleTracks.findIndex(
|
||||||
value: -1,
|
(t) => t.Index === currentSubtitleIndex,
|
||||||
selected: currentSubtitleIndex === -1,
|
);
|
||||||
};
|
return trackIdx >= 0 ? trackIdx + 1 : 0; // +1 because "None" is at index 0
|
||||||
const trackOptions = subtitleTracks.map((track) => ({
|
}, [subtitleTracks, currentSubtitleIndex]);
|
||||||
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, {
|
||||||
@@ -536,10 +545,18 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
]).start();
|
]).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
|
// Delay rendering to work around hasTVPreferredFocus timing issue
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -550,22 +567,23 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
// Programmatic focus fallback
|
// Lazy loading: search when Download tab is first activated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isReady && firstTrackCardRef.current) {
|
if (visible && activeTab === "download" && !hasSearchedThisSession) {
|
||||||
const timer = setTimeout(() => {
|
search({ language: selectedLanguage });
|
||||||
(firstTrackCardRef.current as any)?.requestTVFocus?.();
|
setHasSearchedThisSession(true);
|
||||||
}, 50);
|
}
|
||||||
|
}, [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);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [isReady]);
|
setIsTabContentReady(false);
|
||||||
|
}, [activeTab, 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(
|
||||||
@@ -579,10 +597,10 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
// Handle track selection
|
// Handle track selection
|
||||||
const handleTrackSelect = useCallback(
|
const handleTrackSelect = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
onSubtitleChange(index);
|
onSubtitleIndexChange(index);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
[onSubtitleChange, onClose],
|
[onSubtitleIndexChange, onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle subtitle download
|
// Handle subtitle download
|
||||||
@@ -614,8 +632,29 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Whether we're in player context (can use local subtitles)
|
// Subset of common languages for TV
|
||||||
const isInPlayer = Boolean(onLocalSubtitleDownloaded);
|
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;
|
if (!visible) return null;
|
||||||
|
|
||||||
@@ -629,53 +668,54 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
|
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
|
||||||
<TVFocusGuideView
|
<TVFocusGuideView
|
||||||
autoFocus
|
|
||||||
trapFocusUp
|
trapFocusUp
|
||||||
trapFocusDown
|
trapFocusDown
|
||||||
trapFocusLeft
|
trapFocusLeft
|
||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
{/* Header with title */}
|
{/* Header with tabs */}
|
||||||
<Text style={styles.title}>
|
<View style={styles.header}>
|
||||||
{t("item_card.subtitles.label").toUpperCase()}
|
<Text style={styles.title}>
|
||||||
</Text>
|
{t("item_card.subtitles.label") || "Subtitles"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tab bar */}
|
||||||
<View style={styles.tabRow}>
|
<View style={styles.tabRow}>
|
||||||
<TVSubtitleTab
|
<TVTabButton
|
||||||
label={t("player.subtitle_tracks") || "Tracks"}
|
label={t("item_card.subtitles.tracks") || "Tracks"}
|
||||||
isActive={activeTab === "tracks"}
|
active={activeTab === "tracks"}
|
||||||
onSelect={() => setActiveTab("tracks")}
|
onSelect={() => setActiveTab("tracks")}
|
||||||
hasTVPreferredFocus={true}
|
/>
|
||||||
/>
|
<TVTabButton
|
||||||
<TVSubtitleTab
|
label={t("player.download") || "Download"}
|
||||||
label={t("player.subtitle_search") || "Search & Download"}
|
active={activeTab === "download"}
|
||||||
isActive={activeTab === "search"}
|
onSelect={() => setActiveTab("download")}
|
||||||
onSelect={() => setActiveTab("search")}
|
/>
|
||||||
/>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tracks Tab Content */}
|
||||||
{activeTab === "tracks" && isReady && (
|
{activeTab === "tracks" && isTabContentReady && (
|
||||||
<View style={styles.tabContent}>
|
<View style={styles.section}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={styles.scrollView}
|
style={styles.tracksScroll}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.tracksScrollContent}
|
||||||
>
|
>
|
||||||
{subtitleOptions.map((option, index) => (
|
{trackOptions.map((option, index) => (
|
||||||
<TVTrackCard
|
<TVTrackCard
|
||||||
key={index}
|
key={option.value}
|
||||||
ref={
|
ref={
|
||||||
index === initialSelectedIndex
|
index === initialSelectedTrackIndex
|
||||||
? firstTrackCardRef
|
? firstTrackRef
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
label={option.label}
|
label={option.label}
|
||||||
|
sublabel={option.sublabel}
|
||||||
selected={option.selected}
|
selected={option.selected}
|
||||||
hasTVPreferredFocus={index === initialSelectedIndex}
|
hasTVPreferredFocus={index === initialSelectedTrackIndex}
|
||||||
onPress={() => handleTrackSelect(option.value)}
|
onPress={() => handleTrackSelect(option.value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -683,23 +723,9 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "search" && (
|
{/* Download Tab Content */}
|
||||||
<View style={styles.tabContent}>
|
{activeTab === "download" && isTabContentReady && (
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Language Selector */}
|
{/* Language Selector */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>
|
<Text style={styles.sectionTitle}>
|
||||||
@@ -708,7 +734,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={styles.scrollView}
|
style={styles.languageScroll}
|
||||||
contentContainerStyle={styles.languageScrollContent}
|
contentContainerStyle={styles.languageScrollContent}
|
||||||
>
|
>
|
||||||
{displayLanguages.map((lang, index) => (
|
{displayLanguages.map((lang, index) => (
|
||||||
@@ -789,7 +815,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={styles.scrollView}
|
style={styles.resultsScroll}
|
||||||
contentContainerStyle={styles.resultsScrollContent}
|
contentContainerStyle={styles.resultsScrollContent}
|
||||||
>
|
>
|
||||||
{searchResults.map((result, index) => (
|
{searchResults.map((result, index) => (
|
||||||
@@ -805,7 +831,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* API Key hint */}
|
{/* API Key hint if no fallback available */}
|
||||||
{!hasOpenSubtitlesApiKey && (
|
{!hasOpenSubtitlesApiKey && (
|
||||||
<View style={styles.apiKeyHint}>
|
<View style={styles.apiKeyHint}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@@ -819,7 +845,7 @@ export const TVSubtitleSheet: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</>
|
||||||
)}
|
)}
|
||||||
</TVFocusGuideView>
|
</TVFocusGuideView>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
@@ -840,7 +866,7 @@ const styles = StyleSheet.create({
|
|||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
},
|
},
|
||||||
sheetContainer: {
|
sheetContainer: {
|
||||||
maxHeight: "75%",
|
maxHeight: "70%",
|
||||||
},
|
},
|
||||||
blurContainer: {
|
blurContainer: {
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
@@ -850,21 +876,19 @@ const styles = StyleSheet.create({
|
|||||||
content: {
|
content: {
|
||||||
paddingTop: 24,
|
paddingTop: 24,
|
||||||
paddingBottom: 48,
|
paddingBottom: 48,
|
||||||
overflow: "visible",
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 18,
|
fontSize: 24,
|
||||||
fontWeight: "500",
|
fontWeight: "600",
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "#fff",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
paddingHorizontal: 48,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 1,
|
|
||||||
},
|
},
|
||||||
tabRow: {
|
tabRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
paddingHorizontal: 48,
|
|
||||||
marginBottom: 16,
|
|
||||||
gap: 24,
|
gap: 24,
|
||||||
},
|
},
|
||||||
tabButton: {
|
tabButton: {
|
||||||
@@ -876,15 +900,24 @@ const styles = StyleSheet.create({
|
|||||||
tabText: {
|
tabText: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
},
|
},
|
||||||
tabContent: {
|
section: {
|
||||||
overflow: "visible",
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
scrollView: {
|
sectionTitle: {
|
||||||
overflow: "visible",
|
fontSize: 14,
|
||||||
},
|
fontWeight: "500",
|
||||||
scrollContent: {
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 12,
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: 48,
|
||||||
paddingVertical: 10,
|
},
|
||||||
|
tracksScroll: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
tracksScrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 8,
|
||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
trackCard: {
|
trackCard: {
|
||||||
@@ -899,33 +932,17 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
trackCardSublabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
checkmark: {
|
checkmark: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
},
|
},
|
||||||
downloadHint: {
|
languageScroll: {
|
||||||
flexDirection: "row",
|
overflow: "visible",
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
languageScrollContent: {
|
languageScrollContent: {
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: 48,
|
||||||
@@ -948,6 +965,9 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
resultsScroll: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
resultsScrollContent: {
|
resultsScrollContent: {
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: 48,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
12
index.js
12
index.js
@@ -2,13 +2,9 @@ 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 - wrap in try-catch in case native module isn't linked
|
// TrackPlayer is not supported on tvOS
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
try {
|
const TrackPlayer = require("react-native-track-player").default;
|
||||||
const TrackPlayer = require("react-native-track-player").default;
|
const { PlaybackService } = require("./services/PlaybackService");
|
||||||
const { PlaybackService } = require("./services/PlaybackService");
|
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
||||||
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("TrackPlayer not available:", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,32 +30,20 @@ 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
|
||||||
// Wrap in try-catch in case native module isn't linked
|
// This prevents the native module from being loaded on TV where it doesn't exist
|
||||||
let TrackPlayerModule: typeof import("react-native-track-player") | null = null;
|
const TrackPlayer = Platform.isTV
|
||||||
if (!Platform.isTV) {
|
? null
|
||||||
try {
|
: require("react-native-track-player").default;
|
||||||
TrackPlayerModule = require("react-native-track-player");
|
|
||||||
} catch (e) {
|
const TrackPlayerModule = Platform.isTV
|
||||||
console.warn("TrackPlayer not available:", e);
|
? null
|
||||||
}
|
: 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"];
|
||||||
// Define types locally since they can't be extracted from conditional import
|
type Progress = NonNullable<typeof TrackPlayerModule>["Progress"];
|
||||||
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 = {
|
||||||
@@ -394,7 +382,7 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
|
|
||||||
// Setup TrackPlayer and AudioStorage
|
// Setup TrackPlayer and AudioStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!TrackPlayer || !Capability) return;
|
if (!TrackPlayer) return;
|
||||||
|
|
||||||
const setupPlayer = async () => {
|
const setupPlayer = async () => {
|
||||||
if (playerSetupRef.current) return;
|
if (playerSetupRef.current) return;
|
||||||
@@ -444,21 +432,21 @@ const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|||||||
|
|
||||||
// Sync repeat mode to TrackPlayer
|
// Sync repeat mode to TrackPlayer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!TrackPlayer || !TPRepeatMode) return;
|
if (!TrackPlayer) return;
|
||||||
|
|
||||||
const syncRepeatMode = async () => {
|
const syncRepeatMode = async () => {
|
||||||
if (!playerSetupRef.current) return;
|
if (!playerSetupRef.current) return;
|
||||||
|
|
||||||
let tpRepeatMode: number;
|
let tpRepeatMode: typeof TPRepeatMode;
|
||||||
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,22 +1,6 @@
|
|||||||
import { Platform } from "react-native";
|
import TrackPlayer, { Event } from "react-native-track-player";
|
||||||
|
|
||||||
// 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());
|
||||||
@@ -29,9 +13,8 @@ export const PlaybackService = async () => {
|
|||||||
TrackPlayer.skipToPrevious(),
|
TrackPlayer.skipToPrevious(),
|
||||||
);
|
);
|
||||||
|
|
||||||
TrackPlayer.addEventListener(
|
TrackPlayer.addEventListener(Event.RemoteSeek, (event) =>
|
||||||
Event.RemoteSeek,
|
TrackPlayer.seekTo(event.position),
|
||||||
(event: { position: number }) => TrackPlayer.seekTo(event.position),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.reset());
|
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.reset());
|
||||||
|
|||||||
@@ -628,6 +628,7 @@
|
|||||||
"search_subtitles": "Search Subtitles",
|
"search_subtitles": "Search Subtitles",
|
||||||
"subtitle_tracks": "Tracks",
|
"subtitle_tracks": "Tracks",
|
||||||
"subtitle_search": "Search & Download",
|
"subtitle_search": "Search & Download",
|
||||||
|
"download": "Download",
|
||||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
@@ -661,7 +662,8 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"label": "Subtitle",
|
"label": "Subtitle",
|
||||||
"none": "None"
|
"none": "None",
|
||||||
|
"tracks": "Tracks"
|
||||||
},
|
},
|
||||||
"show_more": "Show More",
|
"show_more": "Show More",
|
||||||
"show_less": "Show Less",
|
"show_less": "Show Less",
|
||||||
|
|||||||
Reference in New Issue
Block a user