mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-30 17:42:51 +01:00
feat(tv): persist downloaded opensubtitles across app restarts
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
getPlaystateApi,
|
getPlaystateApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { File } from "expo-file-system";
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -49,6 +50,7 @@ import { useInactivity } from "@/providers/InactivityProvider";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
@@ -1075,6 +1077,28 @@ export default function page() {
|
|||||||
if (settings.mpvSubtitleAlignY !== undefined) {
|
if (settings.mpvSubtitleAlignY !== undefined) {
|
||||||
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
|
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
|
||||||
}
|
}
|
||||||
|
// Apply subtitle background (iOS only - doesn't work on tvOS due to composite OSD limitation)
|
||||||
|
// mpv uses #RRGGBBAA format (alpha last, same as CSS)
|
||||||
|
if (settings.mpvSubtitleBackgroundEnabled) {
|
||||||
|
const opacity = settings.mpvSubtitleBackgroundOpacity ?? 75;
|
||||||
|
const alphaHex = Math.round((opacity / 100) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0")
|
||||||
|
.toUpperCase();
|
||||||
|
// Enable background-box mode (required for sub-back-color to work)
|
||||||
|
await videoRef.current?.setSubtitleBorderStyle?.("background-box");
|
||||||
|
await videoRef.current?.setSubtitleBackgroundColor?.(
|
||||||
|
`#000000${alphaHex}`,
|
||||||
|
);
|
||||||
|
// Force override ASS subtitle styles so background shows on styled subtitles
|
||||||
|
await videoRef.current?.setSubtitleAssOverride?.("force");
|
||||||
|
} else {
|
||||||
|
// Restore default outline-and-shadow style
|
||||||
|
await videoRef.current?.setSubtitleBorderStyle?.("outline-and-shadow");
|
||||||
|
await videoRef.current?.setSubtitleBackgroundColor?.("#00000000");
|
||||||
|
// Restore default ASS behavior (keep original styles)
|
||||||
|
await videoRef.current?.setSubtitleAssOverride?.("no");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
applySubtitleSettings();
|
applySubtitleSettings();
|
||||||
@@ -1094,6 +1118,28 @@ export default function page() {
|
|||||||
applyInitialPlaybackSpeed();
|
applyInitialPlaybackSpeed();
|
||||||
}, [isVideoLoaded, initialPlaybackSpeed]);
|
}, [isVideoLoaded, initialPlaybackSpeed]);
|
||||||
|
|
||||||
|
// TV only: Pre-load locally downloaded subtitles when video loads
|
||||||
|
// This adds them to MPV's track list without auto-selecting them
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Platform.isTV || !isVideoLoaded || !videoRef.current || !itemId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const preloadLocalSubtitles = async () => {
|
||||||
|
const localSubs = getSubtitlesForItem(itemId);
|
||||||
|
for (const sub of localSubs) {
|
||||||
|
// Verify file still exists (cache may have been cleared)
|
||||||
|
const subtitleFile = new File(sub.filePath);
|
||||||
|
if (!subtitleFile.exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Add subtitle file to MPV without selecting it (select: false)
|
||||||
|
await videoRef.current?.addSubtitleFile?.(sub.filePath, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
preloadLocalSubtitles();
|
||||||
|
}, [isVideoLoaded, itemId]);
|
||||||
|
|
||||||
// Show error UI first, before checking loading/missing‐data
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -659,8 +659,30 @@ export default function TVSubtitleModal() {
|
|||||||
|
|
||||||
// Do NOT close modal - user can see and select the new track
|
// Do NOT close modal - user can see and select the new track
|
||||||
} else if (downloadResult.type === "local" && downloadResult.path) {
|
} else if (downloadResult.type === "local" && downloadResult.path) {
|
||||||
|
// Notify parent that a local subtitle was downloaded
|
||||||
modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
|
modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
|
||||||
handleClose(); // Only close for local downloads
|
|
||||||
|
// Check if component is still mounted after callback
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
// Refresh tracks to include the newly downloaded subtitle
|
||||||
|
if (modalState?.refreshSubtitleTracks) {
|
||||||
|
const newTracks = await modalState.refreshSubtitleTracks();
|
||||||
|
|
||||||
|
// Check if component is still mounted after fetching tracks
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
// Update atom with new tracks
|
||||||
|
store.set(tvSubtitleModalAtom, {
|
||||||
|
...modalState,
|
||||||
|
subtitleTracks: newTracks,
|
||||||
|
});
|
||||||
|
// Switch to tracks tab to show the new subtitle
|
||||||
|
setActiveTab("tracks");
|
||||||
|
} else {
|
||||||
|
// No refreshSubtitleTracks available (e.g., from player), just close
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to download subtitle:", error);
|
console.error("Failed to download subtitle:", error);
|
||||||
@@ -685,13 +707,17 @@ export default function TVSubtitleModal() {
|
|||||||
value: -1,
|
value: -1,
|
||||||
selected: currentSubtitleIndex === -1,
|
selected: currentSubtitleIndex === -1,
|
||||||
setTrack: () => modalState?.onDisableSubtitles?.(),
|
setTrack: () => modalState?.onDisableSubtitles?.(),
|
||||||
|
isLocal: false,
|
||||||
};
|
};
|
||||||
const options = subtitleTracks.map((track: Track) => ({
|
const options = subtitleTracks.map((track: Track) => ({
|
||||||
label: track.name,
|
label: track.name,
|
||||||
sublabel: undefined as string | undefined,
|
sublabel: track.isLocal
|
||||||
|
? t("player.downloaded") || "Downloaded"
|
||||||
|
: (undefined as string | undefined),
|
||||||
value: track.index,
|
value: track.index,
|
||||||
selected: track.index === currentSubtitleIndex,
|
selected: track.index === currentSubtitleIndex,
|
||||||
setTrack: track.setTrack,
|
setTrack: track.setTrack,
|
||||||
|
isLocal: track.isLocal ?? false,
|
||||||
}));
|
}));
|
||||||
return [noneOption, ...options];
|
return [noneOption, ...options];
|
||||||
}, [subtitleTracks, currentSubtitleIndex, t, modalState]);
|
}, [subtitleTracks, currentSubtitleIndex, t, modalState]);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
|
import { File } from "expo-file-system";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -50,6 +51,7 @@ import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
|||||||
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
|
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
@@ -243,9 +245,16 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// State to trigger refresh of local subtitles list
|
||||||
|
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// Starting index for local (client-downloaded) subtitles
|
||||||
|
const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||||
|
|
||||||
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
||||||
|
// Also includes locally downloaded subtitles from OpenSubtitles
|
||||||
const subtitleTracksForModal = useMemo((): Track[] => {
|
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||||
return subtitleStreams.map((stream) => ({
|
const tracks: Track[] = subtitleStreams.map((stream) => ({
|
||||||
name:
|
name:
|
||||||
stream.DisplayTitle ||
|
stream.DisplayTitle ||
|
||||||
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||||
@@ -254,7 +263,37 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, [subtitleStreams]);
|
|
||||||
|
// Add locally downloaded subtitles (from OpenSubtitles)
|
||||||
|
if (item?.Id) {
|
||||||
|
const localSubs = getSubtitlesForItem(item.Id);
|
||||||
|
let localIdx = 0;
|
||||||
|
for (const localSub of localSubs) {
|
||||||
|
// Verify file still exists (cache may have been cleared)
|
||||||
|
const subtitleFile = new File(localSub.filePath);
|
||||||
|
if (!subtitleFile.exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
|
||||||
|
tracks.push({
|
||||||
|
name: localSub.name,
|
||||||
|
index: localIndex,
|
||||||
|
isLocal: true,
|
||||||
|
localPath: localSub.filePath,
|
||||||
|
setTrack: () => {
|
||||||
|
// For ItemContent (outside player), just update the selected index
|
||||||
|
// The actual subtitle will be loaded when playback starts
|
||||||
|
handleSubtitleChangeRef.current?.(localIndex);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
localIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [subtitleStreams, item?.Id, localSubtitlesRefreshKey]);
|
||||||
|
|
||||||
// Get available media sources
|
// Get available media sources
|
||||||
const mediaSources = useMemo(() => {
|
const mediaSources = useMemo(() => {
|
||||||
@@ -346,6 +385,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
}
|
}
|
||||||
}, [item?.Id, queryClient]);
|
}, [item?.Id, queryClient]);
|
||||||
|
|
||||||
|
// Handle local subtitle download - trigger refresh of subtitle tracks
|
||||||
|
const handleLocalSubtitleDownloaded = useCallback((_path: string) => {
|
||||||
|
// Increment the refresh key to trigger re-computation of subtitleTracksForModal
|
||||||
|
setLocalSubtitlesRefreshKey((prev) => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
|
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
|
||||||
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
|
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
|
||||||
if (!api || !item?.Id) return [];
|
if (!api || !item?.Id) return [];
|
||||||
@@ -373,7 +418,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
// Convert to Track[] with setTrack callbacks
|
// Convert to Track[] with setTrack callbacks
|
||||||
return streams.map((stream) => ({
|
const tracks: Track[] = streams.map((stream) => ({
|
||||||
name:
|
name:
|
||||||
stream.DisplayTitle ||
|
stream.DisplayTitle ||
|
||||||
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||||
@@ -382,6 +427,30 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Add locally downloaded subtitles
|
||||||
|
if (item?.Id) {
|
||||||
|
const localSubs = getSubtitlesForItem(item.Id);
|
||||||
|
let localIdx = 0;
|
||||||
|
for (const localSub of localSubs) {
|
||||||
|
const subtitleFile = new File(localSub.filePath);
|
||||||
|
if (!subtitleFile.exists) continue;
|
||||||
|
|
||||||
|
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
|
||||||
|
tracks.push({
|
||||||
|
name: localSub.name,
|
||||||
|
index: localIndex,
|
||||||
|
isLocal: true,
|
||||||
|
localPath: localSub.filePath,
|
||||||
|
setTrack: () => {
|
||||||
|
handleSubtitleChangeRef.current?.(localIndex);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
localIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to refresh subtitle tracks:", error);
|
console.error("Failed to refresh subtitle tracks:", error);
|
||||||
return [];
|
return [];
|
||||||
@@ -399,13 +468,30 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
const selectedSubtitleLabel = useMemo(() => {
|
const selectedSubtitleLabel = useMemo(() => {
|
||||||
if (selectedOptions?.subtitleIndex === -1)
|
if (selectedOptions?.subtitleIndex === -1)
|
||||||
return t("item_card.subtitles.none");
|
return t("item_card.subtitles.none");
|
||||||
|
|
||||||
|
// Check if it's a local subtitle (negative index starting at -100)
|
||||||
|
if (
|
||||||
|
selectedOptions?.subtitleIndex !== undefined &&
|
||||||
|
selectedOptions.subtitleIndex <= LOCAL_SUBTITLE_INDEX_START
|
||||||
|
) {
|
||||||
|
const localTrack = subtitleTracksForModal.find(
|
||||||
|
(t) => t.index === selectedOptions.subtitleIndex,
|
||||||
|
);
|
||||||
|
return localTrack?.name || t("item_card.subtitles.label");
|
||||||
|
}
|
||||||
|
|
||||||
const track = subtitleStreams.find(
|
const track = subtitleStreams.find(
|
||||||
(t) => t.Index === selectedOptions?.subtitleIndex,
|
(t) => t.Index === selectedOptions?.subtitleIndex,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
||||||
);
|
);
|
||||||
}, [subtitleStreams, selectedOptions?.subtitleIndex, t]);
|
}, [
|
||||||
|
subtitleStreams,
|
||||||
|
subtitleTracksForModal,
|
||||||
|
selectedOptions?.subtitleIndex,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectedMediaSourceLabel = useMemo(() => {
|
const selectedMediaSourceLabel = useMemo(() => {
|
||||||
const source = selectedOptions?.mediaSource;
|
const source = selectedOptions?.mediaSource;
|
||||||
@@ -742,6 +828,8 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
onDisableSubtitles: () => handleSubtitleChange(-1),
|
onDisableSubtitles: () => handleSubtitleChange(-1),
|
||||||
onServerSubtitleDownloaded:
|
onServerSubtitleDownloaded:
|
||||||
handleServerSubtitleDownloaded,
|
handleServerSubtitleDownloaded,
|
||||||
|
onLocalSubtitleDownloaded:
|
||||||
|
handleLocalSubtitleDownloaded,
|
||||||
refreshSubtitleTracks,
|
refreshSubtitleTracks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { File } from "expo-file-system";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
@@ -57,13 +58,19 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import type { MpvAudioTrack } from "@/modules";
|
import type { MpvAudioTrack } from "@/modules";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import type { Track } from "../types";
|
import type { Track } from "../types";
|
||||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||||
|
|
||||||
|
// Starting index for local (client-downloaded) subtitles
|
||||||
|
// Uses negative indices to avoid collision with Jellyfin indices
|
||||||
|
const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||||
|
|
||||||
interface VideoContextProps {
|
interface VideoContextProps {
|
||||||
subtitleTracks: Track[] | null;
|
subtitleTracks: Track[] | null;
|
||||||
audioTracks: Track[] | null;
|
audioTracks: Track[] | null;
|
||||||
@@ -339,12 +346,40 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TV only: Merge locally downloaded subtitles (from OpenSubtitles)
|
||||||
|
if (Platform.isTV && itemId) {
|
||||||
|
const localSubs = getSubtitlesForItem(itemId);
|
||||||
|
let localIdx = 0;
|
||||||
|
for (const localSub of localSubs) {
|
||||||
|
// Verify file still exists (cache may have been cleared)
|
||||||
|
const subtitleFile = new File(localSub.filePath);
|
||||||
|
if (!subtitleFile.exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
|
||||||
|
subs.push({
|
||||||
|
name: localSub.name,
|
||||||
|
index: localIndex,
|
||||||
|
mpvIndex: -1, // Will be loaded dynamically via addSubtitleFile
|
||||||
|
isLocal: true,
|
||||||
|
localPath: localSub.filePath,
|
||||||
|
setTrack: () => {
|
||||||
|
// Add the subtitle file to MPV and select it
|
||||||
|
playerControls.addSubtitleFile(localSub.filePath, true);
|
||||||
|
router.setParams({ subtitleIndex: String(localIndex) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
localIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||||
setAudioTracks(audio);
|
setAudioTracks(audio);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTracks();
|
fetchTracks();
|
||||||
}, [tracksReady, mediaSource, offline, downloadedItem]);
|
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ type Track = {
|
|||||||
index: number;
|
index: number;
|
||||||
mpvIndex?: number;
|
mpvIndex?: number;
|
||||||
setTrack: () => void;
|
setTrack: () => void;
|
||||||
|
/** True for client-side downloaded subtitles (e.g., from OpenSubtitles) */
|
||||||
|
isLocal?: boolean;
|
||||||
|
/** File path for local subtitles */
|
||||||
|
localPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { Directory, File, Paths } from "expo-file-system";
|
import { Directory, File, Paths } from "expo-file-system";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
addDownloadedSubtitle,
|
||||||
|
type DownloadedSubtitle,
|
||||||
|
} from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
OpenSubtitlesApi,
|
OpenSubtitlesApi,
|
||||||
@@ -185,32 +190,70 @@ export function useRemoteSubtitles({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Download subtitle via OpenSubtitles API (returns local file path)
|
* Download subtitle via OpenSubtitles API (returns local file path)
|
||||||
|
*
|
||||||
|
* On TV: Downloads to cache directory and persists metadata in MMKV
|
||||||
|
* On mobile: Downloads to cache directory (ephemeral, no persistence)
|
||||||
|
*
|
||||||
|
* Uses a flat filename structure with itemId prefix to avoid tvOS permission issues
|
||||||
*/
|
*/
|
||||||
const downloadOpenSubtitles = useCallback(
|
const downloadOpenSubtitles = useCallback(
|
||||||
async (fileId: number): Promise<string> => {
|
async (
|
||||||
|
fileId: number,
|
||||||
|
result: SubtitleSearchResult,
|
||||||
|
): Promise<{ path: string; subtitle?: DownloadedSubtitle }> => {
|
||||||
if (!openSubtitlesApi) {
|
if (!openSubtitlesApi) {
|
||||||
throw new Error("OpenSubtitles API key not configured");
|
throw new Error("OpenSubtitles API key not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get download link
|
// Get download link
|
||||||
const response = await openSubtitlesApi.download(fileId);
|
const response = await openSubtitlesApi.download(fileId);
|
||||||
|
const originalFileName = response.file_name || `subtitle_${fileId}.srt`;
|
||||||
|
|
||||||
// Download to cache directory
|
// Use cache directory for both platforms (tvOS has permission issues with documents)
|
||||||
const fileName = response.file_name || `subtitle_${fileId}.srt`;
|
// TV: Uses itemId prefix for organization and persists metadata
|
||||||
const subtitlesDir = new Directory(Paths.cache, "subtitles");
|
// Mobile: Simple filename, no persistence
|
||||||
|
const subtitlesDir = new Directory(Paths.cache, "streamyfin-subtitles");
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
if (!subtitlesDir.exists) {
|
if (!subtitlesDir.exists) {
|
||||||
subtitlesDir.create({ intermediates: true });
|
subtitlesDir.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TV: Prefix filename with itemId for organization
|
||||||
|
// Mobile: Use original filename
|
||||||
|
const fileName = Platform.isTV
|
||||||
|
? `${itemId}_${originalFileName}`
|
||||||
|
: originalFileName;
|
||||||
|
|
||||||
// Create file and download
|
// Create file and download
|
||||||
const destination = new File(subtitlesDir, fileName);
|
const destination = new File(subtitlesDir, fileName);
|
||||||
|
|
||||||
|
// Delete existing file if it exists (re-download)
|
||||||
|
if (destination.exists) {
|
||||||
|
destination.delete();
|
||||||
|
}
|
||||||
|
|
||||||
await File.downloadFileAsync(response.link, destination);
|
await File.downloadFileAsync(response.link, destination);
|
||||||
|
|
||||||
return destination.uri;
|
// TV: Persist metadata for future sessions
|
||||||
|
if (Platform.isTV) {
|
||||||
|
const subtitleMetadata: DownloadedSubtitle = {
|
||||||
|
id: result.id,
|
||||||
|
itemId,
|
||||||
|
filePath: destination.uri,
|
||||||
|
name: result.name,
|
||||||
|
language: result.language,
|
||||||
|
format: result.format,
|
||||||
|
source: "opensubtitles",
|
||||||
|
downloadedAt: Date.now(),
|
||||||
|
};
|
||||||
|
addDownloadedSubtitle(subtitleMetadata);
|
||||||
|
return { path: destination.uri, subtitle: subtitleMetadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: destination.uri };
|
||||||
},
|
},
|
||||||
[openSubtitlesApi],
|
[openSubtitlesApi, itemId],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,8 +300,11 @@ export function useRemoteSubtitles({
|
|||||||
return { type: "server" as const };
|
return { type: "server" as const };
|
||||||
}
|
}
|
||||||
if (result.fileId) {
|
if (result.fileId) {
|
||||||
const localPath = await downloadOpenSubtitles(result.fileId);
|
const { path, subtitle } = await downloadOpenSubtitles(
|
||||||
return { type: "local" as const, path: localPath };
|
result.fileId,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
return { type: "local" as const, path, subtitle };
|
||||||
}
|
}
|
||||||
throw new Error("Invalid subtitle result");
|
throw new Error("Invalid subtitle result");
|
||||||
},
|
},
|
||||||
|
|||||||
162
utils/atoms/downloadedSubtitles.ts
Normal file
162
utils/atoms/downloadedSubtitles.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Downloaded Subtitles Storage
|
||||||
|
*
|
||||||
|
* Persists metadata about client-side downloaded subtitles (from OpenSubtitles).
|
||||||
|
* Subtitle files are stored in Paths.cache/streamyfin-subtitles/ directory.
|
||||||
|
* Filenames are prefixed with itemId for organization: {itemId}_{filename}
|
||||||
|
*
|
||||||
|
* While files are in cache, metadata is persisted in MMKV so subtitles survive
|
||||||
|
* app restarts (unless cache is manually cleared by the user).
|
||||||
|
*
|
||||||
|
* TV platform only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { storage } from "../mmkv";
|
||||||
|
|
||||||
|
// MMKV storage key
|
||||||
|
const DOWNLOADED_SUBTITLES_KEY = "downloadedSubtitles.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for a downloaded subtitle file
|
||||||
|
*/
|
||||||
|
export interface DownloadedSubtitle {
|
||||||
|
/** Unique identifier (uuid) */
|
||||||
|
id: string;
|
||||||
|
/** Jellyfin item ID */
|
||||||
|
itemId: string;
|
||||||
|
/** Local file path in documents directory */
|
||||||
|
filePath: string;
|
||||||
|
/** Display name */
|
||||||
|
name: string;
|
||||||
|
/** 3-letter language code */
|
||||||
|
language: string;
|
||||||
|
/** File format (srt, ass, etc.) */
|
||||||
|
format: string;
|
||||||
|
/** Source provider */
|
||||||
|
source: "opensubtitles";
|
||||||
|
/** Unix timestamp when downloaded */
|
||||||
|
downloadedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage structure for downloaded subtitles
|
||||||
|
*/
|
||||||
|
interface DownloadedSubtitlesStorage {
|
||||||
|
/** Map of itemId to array of downloaded subtitles */
|
||||||
|
byItemId: Record<string, DownloadedSubtitle[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the storage from MMKV
|
||||||
|
*/
|
||||||
|
function loadStorage(): DownloadedSubtitlesStorage {
|
||||||
|
try {
|
||||||
|
const data = storage.getString(DOWNLOADED_SUBTITLES_KEY);
|
||||||
|
if (data) {
|
||||||
|
return JSON.parse(data) as DownloadedSubtitlesStorage;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors, return empty storage
|
||||||
|
}
|
||||||
|
return { byItemId: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the storage to MMKV
|
||||||
|
*/
|
||||||
|
function saveStorage(data: DownloadedSubtitlesStorage): void {
|
||||||
|
try {
|
||||||
|
storage.set(DOWNLOADED_SUBTITLES_KEY, JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save downloaded subtitles:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all downloaded subtitles for a specific Jellyfin item
|
||||||
|
*/
|
||||||
|
export function getSubtitlesForItem(itemId: string): DownloadedSubtitle[] {
|
||||||
|
const data = loadStorage();
|
||||||
|
return data.byItemId[itemId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a downloaded subtitle to storage
|
||||||
|
*/
|
||||||
|
export function addDownloadedSubtitle(subtitle: DownloadedSubtitle): void {
|
||||||
|
const data = loadStorage();
|
||||||
|
|
||||||
|
// Initialize array for item if it doesn't exist
|
||||||
|
if (!data.byItemId[subtitle.itemId]) {
|
||||||
|
data.byItemId[subtitle.itemId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if subtitle with same id already exists and update it
|
||||||
|
const existingIndex = data.byItemId[subtitle.itemId].findIndex(
|
||||||
|
(s) => s.id === subtitle.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
// Update existing entry
|
||||||
|
data.byItemId[subtitle.itemId][existingIndex] = subtitle;
|
||||||
|
} else {
|
||||||
|
// Add new entry
|
||||||
|
data.byItemId[subtitle.itemId].push(subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStorage(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a downloaded subtitle from storage
|
||||||
|
*/
|
||||||
|
export function removeDownloadedSubtitle(
|
||||||
|
itemId: string,
|
||||||
|
subtitleId: string,
|
||||||
|
): void {
|
||||||
|
const data = loadStorage();
|
||||||
|
|
||||||
|
if (data.byItemId[itemId]) {
|
||||||
|
data.byItemId[itemId] = data.byItemId[itemId].filter(
|
||||||
|
(s) => s.id !== subtitleId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up empty arrays
|
||||||
|
if (data.byItemId[itemId].length === 0) {
|
||||||
|
delete data.byItemId[itemId];
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStorage(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all downloaded subtitles for a specific item
|
||||||
|
*/
|
||||||
|
export function removeAllSubtitlesForItem(itemId: string): void {
|
||||||
|
const data = loadStorage();
|
||||||
|
|
||||||
|
if (data.byItemId[itemId]) {
|
||||||
|
delete data.byItemId[itemId];
|
||||||
|
saveStorage(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a subtitle file already exists for an item by language
|
||||||
|
*/
|
||||||
|
export function hasSubtitleForLanguage(
|
||||||
|
itemId: string,
|
||||||
|
language: string,
|
||||||
|
): boolean {
|
||||||
|
const subtitles = getSubtitlesForItem(itemId);
|
||||||
|
return subtitles.some((s) => s.language === language);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all downloaded subtitles across all items
|
||||||
|
*/
|
||||||
|
export function getAllDownloadedSubtitles(): DownloadedSubtitle[] {
|
||||||
|
const data = loadStorage();
|
||||||
|
return Object.values(data.byItemId).flat();
|
||||||
|
}
|
||||||
@@ -87,6 +87,58 @@ export class OpenSubtitlesApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping between ISO 639-1 (2-letter) and ISO 639-2B (3-letter) language codes
|
||||||
|
*/
|
||||||
|
const ISO_639_MAPPING: Record<string, string> = {
|
||||||
|
en: "eng",
|
||||||
|
es: "spa",
|
||||||
|
fr: "fre",
|
||||||
|
de: "ger",
|
||||||
|
it: "ita",
|
||||||
|
pt: "por",
|
||||||
|
ru: "rus",
|
||||||
|
ja: "jpn",
|
||||||
|
ko: "kor",
|
||||||
|
zh: "chi",
|
||||||
|
ar: "ara",
|
||||||
|
pl: "pol",
|
||||||
|
nl: "dut",
|
||||||
|
sv: "swe",
|
||||||
|
no: "nor",
|
||||||
|
da: "dan",
|
||||||
|
fi: "fin",
|
||||||
|
tr: "tur",
|
||||||
|
cs: "cze",
|
||||||
|
el: "gre",
|
||||||
|
he: "heb",
|
||||||
|
hu: "hun",
|
||||||
|
ro: "rum",
|
||||||
|
th: "tha",
|
||||||
|
vi: "vie",
|
||||||
|
id: "ind",
|
||||||
|
ms: "may",
|
||||||
|
bg: "bul",
|
||||||
|
hr: "hrv",
|
||||||
|
sk: "slo",
|
||||||
|
sl: "slv",
|
||||||
|
uk: "ukr",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reverse mapping: 3-letter to 2-letter
|
||||||
|
const ISO_639_REVERSE: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(ISO_639_MAPPING).map(([k, v]) => [v, k]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code
|
||||||
|
* OpenSubtitles REST API uses 2-letter codes
|
||||||
|
*/
|
||||||
|
function toIso6391(code: string): string {
|
||||||
|
if (code.length === 2) return code;
|
||||||
|
return ISO_639_REVERSE[code.toLowerCase()] || code;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenSubtitles API client for direct subtitle fetching
|
* OpenSubtitles API client for direct subtitle fetching
|
||||||
*/
|
*/
|
||||||
@@ -138,7 +190,7 @@ export class OpenSubtitlesApi {
|
|||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params.imdbId) {
|
if (params.imdbId) {
|
||||||
// Ensure IMDB ID has correct format (with "tt" prefix)
|
// Ensure IMDB ID has "tt" prefix
|
||||||
const imdbId = params.imdbId.startsWith("tt")
|
const imdbId = params.imdbId.startsWith("tt")
|
||||||
? params.imdbId
|
? params.imdbId
|
||||||
: `tt${params.imdbId}`;
|
: `tt${params.imdbId}`;
|
||||||
@@ -151,7 +203,12 @@ export class OpenSubtitlesApi {
|
|||||||
queryParams.set("year", params.year.toString());
|
queryParams.set("year", params.year.toString());
|
||||||
}
|
}
|
||||||
if (params.languages) {
|
if (params.languages) {
|
||||||
queryParams.set("languages", params.languages);
|
// Convert 3-letter codes to 2-letter codes (API uses ISO 639-1)
|
||||||
|
const lang =
|
||||||
|
params.languages.length === 3
|
||||||
|
? toIso6391(params.languages)
|
||||||
|
: params.languages;
|
||||||
|
queryParams.set("languages", lang);
|
||||||
}
|
}
|
||||||
if (params.seasonNumber !== undefined) {
|
if (params.seasonNumber !== undefined) {
|
||||||
queryParams.set("season_number", params.seasonNumber.toString());
|
queryParams.set("season_number", params.seasonNumber.toString());
|
||||||
@@ -179,50 +236,18 @@ export class OpenSubtitlesApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ISO 639-2B (3-letter) to ISO 639-1 (2-letter) language code
|
||||||
|
* Exported for external use
|
||||||
|
*/
|
||||||
|
export { toIso6391 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert ISO 639-1 (2-letter) to ISO 639-2B (3-letter) language code
|
* Convert ISO 639-1 (2-letter) to ISO 639-2B (3-letter) language code
|
||||||
* OpenSubtitles uses ISO 639-2B codes
|
|
||||||
*/
|
*/
|
||||||
export function toIso6392B(code: string): string {
|
export function toIso6392B(code: string): string {
|
||||||
const mapping: Record<string, string> = {
|
|
||||||
en: "eng",
|
|
||||||
es: "spa",
|
|
||||||
fr: "fre",
|
|
||||||
de: "ger",
|
|
||||||
it: "ita",
|
|
||||||
pt: "por",
|
|
||||||
ru: "rus",
|
|
||||||
ja: "jpn",
|
|
||||||
ko: "kor",
|
|
||||||
zh: "chi",
|
|
||||||
ar: "ara",
|
|
||||||
pl: "pol",
|
|
||||||
nl: "dut",
|
|
||||||
sv: "swe",
|
|
||||||
no: "nor",
|
|
||||||
da: "dan",
|
|
||||||
fi: "fin",
|
|
||||||
tr: "tur",
|
|
||||||
cs: "cze",
|
|
||||||
el: "gre",
|
|
||||||
he: "heb",
|
|
||||||
hu: "hun",
|
|
||||||
ro: "rum",
|
|
||||||
th: "tha",
|
|
||||||
vi: "vie",
|
|
||||||
id: "ind",
|
|
||||||
ms: "may",
|
|
||||||
bg: "bul",
|
|
||||||
hr: "hrv",
|
|
||||||
sk: "slo",
|
|
||||||
sl: "slv",
|
|
||||||
uk: "ukr",
|
|
||||||
};
|
|
||||||
|
|
||||||
// If already 3 letters, return as-is
|
|
||||||
if (code.length === 3) return code;
|
if (code.length === 3) return code;
|
||||||
|
return ISO_639_MAPPING[code.toLowerCase()] || code;
|
||||||
return mapping[code.toLowerCase()] || code;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user