diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx
index b3d905fa..ae2aa87d 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx
@@ -1,4 +1,3 @@
-import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
import { useLocalSearchParams } from "expo-router";
import type React from "react";
import { useEffect } from "react";
@@ -21,14 +20,8 @@ const Page: React.FC = () => {
const { offline } = useLocalSearchParams() as { offline?: string };
const isOffline = offline === "true";
- const { data: item, isError } = useItemQuery(id, false, undefined, [
- ItemFields.MediaSources,
- ItemFields.MediaSourceCount,
- ItemFields.MediaStreams,
- ]);
-
- // preload media sources
- const { data: itemWithSources } = useItemQuery(id, false, undefined, []);
+ // Fetch item with all fields including MediaSources
+ const { data: item, isError } = useItemQuery(id, isOffline, undefined, []);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
@@ -98,13 +91,7 @@ const Page: React.FC = () => {
- {item && (
-
- )}
+ {item && }
);
};
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index 763166a9..406c1ce4 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -22,6 +22,8 @@ import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
+import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
+import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
@@ -32,12 +34,17 @@ import {
type MpvPlayerViewRef,
type OnPlaybackStateChangePayload,
type OnProgressEventPayload,
+ type VideoSource,
} from "@/modules";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import {
+ getMpvAudioId,
+ getMpvSubtitleId,
+} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
@@ -62,6 +69,7 @@ export default function page() {
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
+ const [trackCount, setTrackCount] = useState(0);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -223,8 +231,6 @@ export default function page() {
return;
}
- const native = generateDeviceProfile();
- const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({
api,
item,
@@ -234,7 +240,7 @@ export default function page() {
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
- deviceProfile: bitrateValue ? transcoding : native,
+ deviceProfile: generateDeviceProfile(),
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
@@ -426,6 +432,46 @@ export default function page() {
return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
+ /** Build video source config for the native player */
+ const videoSource = useMemo(() => {
+ if (!stream?.url) return undefined;
+
+ const mediaSource = stream.mediaSource;
+ const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
+
+ // Get external subtitle URLs
+ const externalSubs = mediaSource?.MediaStreams?.filter(
+ (s) =>
+ s.Type === "Subtitle" &&
+ s.DeliveryMethod === "External" &&
+ s.DeliveryUrl,
+ ).map((s) => `${api?.basePath}${s.DeliveryUrl}`);
+
+ // Calculate MPV track IDs for initial selection
+ const initialSubtitleId = getMpvSubtitleId(
+ mediaSource,
+ subtitleIndex,
+ isTranscoding,
+ );
+ const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
+
+ return {
+ url: stream.url,
+ startPosition,
+ autoplay: true,
+ externalSubtitles: externalSubs,
+ initialSubtitleId,
+ initialAudioId,
+ };
+ }, [
+ stream?.url,
+ stream?.mediaSource,
+ startPosition,
+ api?.basePath,
+ subtitleIndex,
+ audioIndex,
+ ]);
+
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
@@ -565,26 +611,6 @@ export default function page() {
videoRef.current?.seekTo?.(position / 1000);
}, []);
- const getSubtitleTracks = useCallback(async () => {
- return videoRef.current?.getSubtitleTracks?.() || null;
- }, []);
-
- const setSubtitleTrack = useCallback((index: number) => {
- videoRef.current?.setSubtitleTrack?.(index);
- }, []);
-
- const setSubtitleURL = useCallback((url: string, _customName?: string) => {
- videoRef.current?.addSubtitleFile?.(url);
- }, []);
-
- const getAudioTracks = useCallback(async () => {
- return videoRef.current?.getAudioTracks?.() || null;
- }, []);
-
- const setAudioTrack = useCallback((index: number) => {
- videoRef.current?.setAudioTrack?.(index);
- }, []);
-
// Apply MPV subtitle settings when video loads
useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return;
@@ -643,81 +669,81 @@ export default function page() {
);
return (
-
-
- {
- setIsVideoLoaded(true);
- // Seek to start position after load
- if (startPosition > 0) {
- videoRef.current?.seekTo(startPosition);
- }
+
+ {
- console.error("Video Error:", e.nativeEvent);
- Alert.alert(
- t("player.error"),
- t("player.an_error_occured_while_playing_the_video"),
- );
- writeToLog("ERROR", "Video Error", e.nativeEvent);
- }}
- />
-
- {isMounted === true && item && !isPipMode && (
-
- )}
-
+ >
+
+ setIsVideoLoaded(true)}
+ onError={(e) => {
+ console.error("Video Error:", e.nativeEvent);
+ Alert.alert(
+ t("player.error"),
+ t("player.an_error_occured_while_playing_the_video"),
+ );
+ writeToLog("ERROR", "Video Error", e.nativeEvent);
+ }}
+ onTracksReady={(e) => {
+ console.log("[Player] Tracks ready:", e.nativeEvent.trackCount);
+ setTrackCount(e.nativeEvent.trackCount);
+ }}
+ />
+
+ {isMounted === true && item && !isPipMode && (
+
+ )}
+
+
+
);
}
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index 6b8f1bba..8f61b855 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -46,11 +46,10 @@ export type SelectedOptions = {
interface ItemContentProps {
item: BaseItemDto;
isOffline: boolean;
- itemWithSources?: BaseItemDto | null;
}
export const ItemContent: React.FC = React.memo(
- ({ item, isOffline, itemWithSources }) => {
+ ({ item, isOffline }) => {
const [api] = useAtom(apiAtom);
const { settings } = useSettings();
const { orientation } = useOrientation();
@@ -72,7 +71,13 @@ export const ItemContent: React.FC = React.memo(
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
- } = useDefaultPlaySettings(item!, settings);
+ } = useDefaultPlaySettings(item, settings);
+
+ console.log("defaultMediaSource", {
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultSubtitleIndex,
+ });
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
@@ -87,7 +92,7 @@ export const ItemContent: React.FC = React.memo(
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
- mediaSource: defaultMediaSource,
+ mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
@@ -99,7 +104,7 @@ export const ItemContent: React.FC = React.memo(
]);
useEffect(() => {
- if (!Platform.isTV && itemWithSources) {
+ if (!Platform.isTV && item) {
navigation.setOptions({
headerRight: () =>
item &&
@@ -109,7 +114,7 @@ export const ItemContent: React.FC = React.memo(
{item.Type !== "Program" && (
{!Platform.isTV && (
-
+
)}
{user?.Policy?.IsAdministrator && (
@@ -126,7 +131,7 @@ export const ItemContent: React.FC = React.memo(
{item.Type !== "Program" && (
{!Platform.isTV && (
-
+
)}
{user?.Policy?.IsAdministrator && (
@@ -140,7 +145,7 @@ export const ItemContent: React.FC = React.memo(
)),
});
}
- }, [item, navigation, user, itemWithSources]);
+ }, [item, navigation, user, item]);
useEffect(() => {
if (item) {
@@ -213,7 +218,7 @@ export const ItemContent: React.FC = React.memo(
)}
diff --git a/components/MediaSourceButton.tsx b/components/MediaSourceButton.tsx
index 05847f35..10cabf77 100644
--- a/components/MediaSourceButton.tsx
+++ b/components/MediaSourceButton.tsx
@@ -104,6 +104,11 @@ export const MediaSourceButton: React.FC = ({
// Audio track group
if (audioStreams.length > 0) {
+ console.log("Audio comparison:", {
+ selectedAudioIndex: selectedOptions.audioIndex,
+ streamIndices: audioStreams.map((s) => s.Index),
+ });
+
groups.push({
title: t("item_card.audio"),
options: audioStreams.map((stream) => ({
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
index 2310c468..b0d2f317 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -7,7 +7,6 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import {
type Dispatch,
type FC,
- type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
@@ -28,7 +27,6 @@ import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
-import type { AudioTrack, MpvPlayerViewRef, SubtitleTrack } from "@/modules";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
@@ -36,7 +34,6 @@ import { ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls";
import { CONTROLS_CONSTANTS } from "./constants";
-import { ControlProvider } from "./contexts/ControlContext";
import { EpisodeList } from "./EpisodeList";
import { GestureOverlay } from "./GestureOverlay";
import { HeaderControls } from "./HeaderControls";
@@ -50,31 +47,21 @@ import { type AspectRatio } from "./VideoScalingModeSelector";
interface Props {
item: BaseItemDto;
- videoRef: MutableRefObject;
isPlaying: boolean;
isSeeking: SharedValue;
cacheProgress: SharedValue;
progress: SharedValue;
isBuffering: boolean;
showControls: boolean;
-
enableTrickplay?: boolean;
togglePlay: () => void;
setShowControls: (shown: boolean) => void;
offline?: boolean;
- isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture?: () => Promise;
play: () => void;
pause: () => void;
- getSubtitleTracks?:
- | (() => Promise)
- | (() => SubtitleTrack[]);
- getAudioTracks?: (() => Promise) | (() => AudioTrack[]);
- setSubtitleURL?: (url: string, customName: string) => void;
- setSubtitleTrack?: (index: number) => void;
- setAudioTrack?: (index: number) => void;
setVideoAspectRatio?: (aspectRatio: string | null) => Promise;
setVideoScaleFactor?: (scaleFactor: number) => Promise;
aspectRatio?: AspectRatio;
@@ -100,12 +87,6 @@ export const Controls: FC = ({
showControls,
setShowControls,
mediaSource,
- isVideoLoaded,
- getSubtitleTracks,
- getAudioTracks,
- setSubtitleURL,
- setSubtitleTrack,
- setAudioTrack,
setVideoAspectRatio,
setVideoScaleFactor,
aspectRatio = "default",
@@ -348,12 +329,10 @@ export const Controls: FC = ({
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
- } = getDefaultPlaySettings(
- item,
- settings,
- previousIndexes,
- mediaSource ?? undefined,
- );
+ } = getDefaultPlaySettings(item, settings, {
+ indexes: previousIndexes,
+ source: mediaSource ?? undefined,
+ });
const queryParams = new URLSearchParams({
...(offline && { offline: "true" }),
@@ -468,11 +447,7 @@ export const Controls: FC = ({
}, [isPlaying, togglePlay]);
return (
-
+ <>
{episodeView ? (
= ({
goToNextItem={goToNextItem}
previousItem={previousItem}
nextItem={nextItem}
- getSubtitleTracks={getSubtitleTracks}
- getAudioTracks={getAudioTracks}
- setSubtitleTrack={setSubtitleTrack}
- setAudioTrack={setAudioTrack}
- setSubtitleURL={setSubtitleURL}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
@@ -570,6 +540,6 @@ export const Controls: FC = ({
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
)}
-
+ >
);
};
diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx
index 5c04baf5..cddb510c 100644
--- a/components/video-player/controls/HeaderControls.tsx
+++ b/components/video-player/controls/HeaderControls.tsx
@@ -15,7 +15,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic";
import { useSettings } from "@/utils/atoms/settings";
import { ICON_SIZES } from "./constants";
-import { VideoProvider } from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView";
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
import {
@@ -34,11 +33,6 @@ interface HeaderControlsProps {
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null;
- getSubtitleTracks?: (() => Promise) | (() => any[]);
- getAudioTracks?: (() => Promise) | (() => any[]);
- setSubtitleTrack?: (index: number) => void;
- setAudioTrack?: (index: number) => void;
- setSubtitleURL?: (url: string, customName: string) => void;
aspectRatio?: AspectRatio;
scaleFactor?: ScaleFactor;
setAspectRatio?: Dispatch>;
@@ -58,11 +52,6 @@ export const HeaderControls: FC = ({
goToNextItem,
previousItem,
nextItem,
- getSubtitleTracks,
- getAudioTracks,
- setSubtitleTrack,
- setAudioTrack,
- setSubtitleURL,
aspectRatio = "default",
scaleFactor = 1.0,
setAspectRatio,
@@ -113,17 +102,9 @@ export const HeaderControls: FC = ({
>
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
-
-
-
-
-
+
+
+
)}
diff --git a/components/video-player/controls/contexts/ControlContext.tsx b/components/video-player/controls/contexts/ControlContext.tsx
deleted file mode 100644
index c13211c9..00000000
--- a/components/video-player/controls/contexts/ControlContext.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import type {
- BaseItemDto,
- MediaSourceInfo,
-} from "@jellyfin/sdk/lib/generated-client";
-import type React from "react";
-import { createContext, type ReactNode, useContext } from "react";
-
-interface ControlContextProps {
- item: BaseItemDto;
- mediaSource: MediaSourceInfo | null | undefined;
- isVideoLoaded: boolean | undefined;
-}
-
-const ControlContext = createContext(
- undefined,
-);
-
-interface ControlProviderProps {
- children: ReactNode;
- item: BaseItemDto;
- mediaSource: MediaSourceInfo | null | undefined;
- isVideoLoaded: boolean | undefined;
-}
-
-export const ControlProvider: React.FC = ({
- children,
- item,
- mediaSource,
- isVideoLoaded,
-}) => {
- return (
-
- {children}
-
- );
-};
-
-export const useControlContext = () => {
- const context = useContext(ControlContext);
- if (context === undefined) {
- throw new Error("useControlContext must be used within a ControlProvider");
- }
- return context;
-};
diff --git a/components/video-player/controls/contexts/PlayerContext.tsx b/components/video-player/controls/contexts/PlayerContext.tsx
new file mode 100644
index 00000000..07f72818
--- /dev/null
+++ b/components/video-player/controls/contexts/PlayerContext.tsx
@@ -0,0 +1,107 @@
+import type {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client";
+import React, {
+ createContext,
+ type MutableRefObject,
+ type ReactNode,
+ useContext,
+ useMemo,
+} from "react";
+import type { MpvPlayerViewRef } from "@/modules";
+
+interface PlayerContextProps {
+ playerRef: MutableRefObject;
+ item: BaseItemDto;
+ mediaSource: MediaSourceInfo | null | undefined;
+ isVideoLoaded: boolean;
+ trackCount: number;
+}
+
+const PlayerContext = createContext(undefined);
+
+interface PlayerProviderProps {
+ children: ReactNode;
+ playerRef: MutableRefObject;
+ item: BaseItemDto;
+ mediaSource: MediaSourceInfo | null | undefined;
+ isVideoLoaded: boolean;
+ trackCount: number;
+}
+
+export const PlayerProvider: React.FC = ({
+ children,
+ playerRef,
+ item,
+ mediaSource,
+ isVideoLoaded,
+ trackCount,
+}) => {
+ const value = useMemo(
+ () => ({ playerRef, item, mediaSource, isVideoLoaded, trackCount }),
+ [playerRef, item, mediaSource, isVideoLoaded, trackCount],
+ );
+
+ return (
+ {children}
+ );
+};
+
+// Core context hook
+export const usePlayerContext = () => {
+ const context = useContext(PlayerContext);
+ if (!context)
+ throw new Error("usePlayerContext must be used within PlayerProvider");
+ return context;
+};
+
+// Player controls hook
+export const usePlayerControls = () => {
+ const { playerRef } = usePlayerContext();
+
+ return {
+ // Subtitle controls
+ getSubtitleTracks: async () => {
+ return playerRef.current?.getSubtitleTracks() ?? null;
+ },
+ setSubtitleTrack: (trackId: number) => {
+ playerRef.current?.setSubtitleTrack(trackId);
+ },
+ disableSubtitles: () => {
+ playerRef.current?.disableSubtitles();
+ },
+ addSubtitleFile: (url: string, select = true) => {
+ playerRef.current?.addSubtitleFile(url, select);
+ },
+
+ // Audio controls
+ getAudioTracks: async () => {
+ return playerRef.current?.getAudioTracks() ?? null;
+ },
+ setAudioTrack: (trackId: number) => {
+ playerRef.current?.setAudioTrack(trackId);
+ },
+
+ // Playback controls
+ play: () => playerRef.current?.play(),
+ pause: () => playerRef.current?.pause(),
+ seekTo: (position: number) => playerRef.current?.seekTo(position),
+ seekBy: (offset: number) => playerRef.current?.seekBy(offset),
+ setSpeed: (speed: number) => playerRef.current?.setSpeed(speed),
+
+ // Subtitle positioning
+ setSubtitleScale: (scale: number) =>
+ playerRef.current?.setSubtitleScale(scale),
+ setSubtitlePosition: (position: number) =>
+ playerRef.current?.setSubtitlePosition(position),
+ setSubtitleMarginY: (margin: number) =>
+ playerRef.current?.setSubtitleMarginY(margin),
+ setSubtitleFontSize: (size: number) =>
+ playerRef.current?.setSubtitleFontSize(size),
+
+ // PiP
+ startPictureInPicture: () => playerRef.current?.startPictureInPicture(),
+ stopPictureInPicture: () => playerRef.current?.stopPictureInPicture(),
+ };
+};
diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx
index d5d77680..73068ccc 100644
--- a/components/video-player/controls/contexts/VideoContext.tsx
+++ b/components/video-player/controls/contexts/VideoContext.tsx
@@ -1,4 +1,73 @@
-import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
+/**
+ * VideoContext.tsx
+ *
+ * Manages subtitle and audio track state for the video player UI.
+ *
+ * ============================================================================
+ * INDEX TYPES
+ * ============================================================================
+ *
+ * We track two different indices for each track:
+ *
+ * 1. SERVER INDEX (sub.Index / track.index)
+ * - Jellyfin's server-side stream index
+ * - Used to report playback state to Jellyfin server
+ * - Allows Jellyfin to remember user's last selected tracks
+ * - Passed via router params (subtitleIndex, audioIndex)
+ * - Value of -1 means disabled/none
+ *
+ * 2. MPV INDEX (track.mpvIndex)
+ * - MPV's internal track ID for the loaded track
+ * - Used to actually switch tracks in the player
+ * - Only assigned to tracks that are loaded into MPV
+ * - Value of -1 means track is not in MPV (e.g., burned-in image sub)
+ *
+ * ============================================================================
+ * SUBTITLE DELIVERY METHODS
+ * ============================================================================
+ *
+ * Jellyfin provides subtitles via different delivery methods:
+ * - Embed: Subtitle is embedded in the container (MKV, MP4, etc.)
+ * - Hls: Subtitle is delivered via HLS segments (during transcoding)
+ * - External: Subtitle is delivered as a separate file URL
+ * - Encode: Subtitle is burned into the video (image-based subs during transcode)
+ *
+ * Jellyfin also provides `IsTextSubtitleStream` boolean:
+ * - true: Text-based subtitle (SRT, ASS, VTT, etc.)
+ * - false: Image-based subtitle (PGS, VOBSUB, DVDSUB, etc.)
+ *
+ * ============================================================================
+ * SUBTITLE TYPES AND HOW THEY'RE HANDLED
+ * ============================================================================
+ *
+ * 1. TEXT-BASED SUBTITLES (IsTextSubtitleStream = true)
+ * - Direct Play: Loaded into MPV (embedded or via sub-add for external)
+ * - Transcoding: Delivered via HLS, loaded into MPV
+ * - Action: Use playerControls.setSubtitleTrack(mpvId)
+ *
+ * 2. IMAGE-BASED SUBTITLES (IsTextSubtitleStream = false)
+ * - Direct Play: Embedded ones are in MPV, external ones are filtered out
+ * - Transcoding: BURNED INTO VIDEO by Jellyfin (not in MPV track list)
+ * - Action: When transcoding, use replacePlayer() to request burn-in
+ *
+ * ============================================================================
+ * MPV INDEX CALCULATION
+ * ============================================================================
+ *
+ * We iterate through Jellyfin's subtitle list and assign MPV indices only to
+ * subtitles that are actually loaded into MPV:
+ *
+ * - isSubtitleInMpv = true: Subtitle is in MPV's track list, increment index
+ * - isSubtitleInMpv = false: Subtitle is NOT in MPV (e.g., image sub during
+ * transcode), do NOT increment index
+ *
+ * The order of subtitles in Jellyfin's MediaStreams matches the order in MPV.
+ */
+
+import {
+ type MediaStream,
+ SubtitleDeliveryMethod,
+} from "@jellyfin/sdk/lib/generated-client";
import { router, useLocalSearchParams } from "expo-router";
import type React from "react";
import {
@@ -11,53 +80,23 @@ import {
} from "react";
import type { AudioTrack, SubtitleTrack } from "@/modules";
import type { Track } from "../types";
-import { useControlContext } from "./ControlContext";
+import { usePlayerContext, usePlayerControls } from "./PlayerContext";
interface VideoContextProps {
subtitleTracks: Track[] | null;
audioTracks: Track[] | null;
- setSubtitleTrack: ((index: number) => void) | undefined;
- setSubtitleURL: ((url: string, customName: string) => void) | undefined;
}
const VideoContext = createContext(undefined);
-interface VideoProviderProps {
- children: ReactNode;
- getSubtitleTracks:
- | (() => Promise)
- | (() => SubtitleTrack[])
- | undefined;
- getAudioTracks:
- | (() => Promise)
- | (() => AudioTrack[])
- | undefined;
- setSubtitleTrack: ((index: number) => void) | undefined;
- setAudioTrack: ((index: number) => void) | undefined;
- setSubtitleURL: ((url: string, customName: string) => void) | undefined;
-}
-
-/**
-s * Video context provider for managing subtitle and audio tracks.
- * MPV player is used for all playback.
- */
-export const VideoProvider: React.FC = ({
+export const VideoProvider: React.FC<{ children: ReactNode }> = ({
children,
- getSubtitleTracks,
- getAudioTracks,
- setSubtitleTrack,
- setAudioTrack,
- setSubtitleURL,
}) => {
const [subtitleTracks, setSubtitleTracks] = useState