mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-16 22:36:25 +00:00
working subs
This commit is contained in:
@@ -46,11 +46,10 @@ export type SelectedOptions = {
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
itemWithSources?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = 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<ItemContentProps> = 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<ItemContentProps> = 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<ItemContentProps> = React.memo(
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV && itemWithSources) {
|
||||
if (!Platform.isTV && item) {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item &&
|
||||
@@ -109,7 +114,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
@@ -126,7 +131,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
@@ -140,7 +145,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
)),
|
||||
});
|
||||
}
|
||||
}, [item, navigation, user, itemWithSources]);
|
||||
}, [item, navigation, user, item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
@@ -213,7 +218,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
<MediaSourceButton
|
||||
selectedOptions={selectedOptions}
|
||||
setSelectedOptions={setSelectedOptions}
|
||||
item={itemWithSources}
|
||||
item={item}
|
||||
colors={itemColors}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -104,6 +104,11 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
|
||||
// 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) => ({
|
||||
|
||||
@@ -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<MpvPlayerViewRef | null>;
|
||||
isPlaying: boolean;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
cacheProgress: SharedValue<number>;
|
||||
progress: SharedValue<number>;
|
||||
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<void>;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
getSubtitleTracks?:
|
||||
| (() => Promise<SubtitleTrack[] | null>)
|
||||
| (() => SubtitleTrack[]);
|
||||
getAudioTracks?: (() => Promise<AudioTrack[] | null>) | (() => AudioTrack[]);
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setAudioTrack?: (index: number) => void;
|
||||
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
||||
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
||||
aspectRatio?: AspectRatio;
|
||||
@@ -100,12 +87,6 @@ export const Controls: FC<Props> = ({
|
||||
showControls,
|
||||
setShowControls,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
getSubtitleTracks,
|
||||
getAudioTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
setVideoAspectRatio,
|
||||
setVideoScaleFactor,
|
||||
aspectRatio = "default",
|
||||
@@ -348,12 +329,10 @@ export const Controls: FC<Props> = ({
|
||||
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<Props> = ({
|
||||
}, [isPlaying, togglePlay]);
|
||||
|
||||
return (
|
||||
<ControlProvider
|
||||
item={item}
|
||||
mediaSource={mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
>
|
||||
<>
|
||||
{episodeView ? (
|
||||
<EpisodeList
|
||||
item={item}
|
||||
@@ -504,11 +479,6 @@ export const Controls: FC<Props> = ({
|
||||
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<Props> = ({
|
||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
||||
)}
|
||||
</ControlProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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[] | null>) | (() => any[]);
|
||||
getAudioTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setAudioTrack?: (index: number) => void;
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
aspectRatio?: AspectRatio;
|
||||
scaleFactor?: ScaleFactor;
|
||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||
@@ -58,11 +52,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
goToNextItem,
|
||||
previousItem,
|
||||
nextItem,
|
||||
getSubtitleTracks,
|
||||
getAudioTracks,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
setSubtitleURL,
|
||||
aspectRatio = "default",
|
||||
scaleFactor = 1.0,
|
||||
setAspectRatio,
|
||||
@@ -113,17 +102,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
>
|
||||
<View className='mr-auto' pointerEvents='box-none'>
|
||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||
<VideoProvider
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
getAudioTracks={getAudioTracks}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
<View pointerEvents='auto'>
|
||||
<DropdownView />
|
||||
</View>
|
||||
</VideoProvider>
|
||||
<View pointerEvents='auto'>
|
||||
<DropdownView />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
|
||||
@@ -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<ControlContextProps | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ControlProviderProps {
|
||||
children: ReactNode;
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean | undefined;
|
||||
}
|
||||
|
||||
export const ControlProvider: React.FC<ControlProviderProps> = ({
|
||||
children,
|
||||
item,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
}) => {
|
||||
return (
|
||||
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
||||
{children}
|
||||
</ControlContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useControlContext = () => {
|
||||
const context = useContext(ControlContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useControlContext must be used within a ControlProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
107
components/video-player/controls/contexts/PlayerContext.tsx
Normal file
107
components/video-player/controls/contexts/PlayerContext.tsx
Normal file
@@ -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<MpvPlayerViewRef | null>;
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean;
|
||||
trackCount: number;
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextProps | undefined>(undefined);
|
||||
|
||||
interface PlayerProviderProps {
|
||||
children: ReactNode;
|
||||
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean;
|
||||
trackCount: number;
|
||||
}
|
||||
|
||||
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
children,
|
||||
playerRef,
|
||||
item,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
trackCount,
|
||||
}) => {
|
||||
const value = useMemo(
|
||||
() => ({ playerRef, item, mediaSource, isVideoLoaded, trackCount }),
|
||||
[playerRef, item, mediaSource, isVideoLoaded, trackCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// 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(),
|
||||
};
|
||||
};
|
||||
@@ -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<VideoContextProps | undefined>(undefined);
|
||||
|
||||
interface VideoProviderProps {
|
||||
children: ReactNode;
|
||||
getSubtitleTracks:
|
||||
| (() => Promise<SubtitleTrack[] | null>)
|
||||
| (() => SubtitleTrack[])
|
||||
| undefined;
|
||||
getAudioTracks:
|
||||
| (() => Promise<AudioTrack[] | null>)
|
||||
| (() => 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<VideoProviderProps> = ({
|
||||
export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
getSubtitleTracks,
|
||||
getAudioTracks,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
setSubtitleURL,
|
||||
}) => {
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
|
||||
const ControlContext = useControlContext();
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
const mediaSource = ControlContext?.mediaSource;
|
||||
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
const { trackCount, mediaSource } = usePlayerContext();
|
||||
const playerControls = usePlayerControls();
|
||||
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
@@ -69,172 +108,167 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
playbackPosition: string;
|
||||
}>();
|
||||
|
||||
const onTextBasedSubtitle = useMemo(() => {
|
||||
return (
|
||||
allSubs.find(
|
||||
(s) =>
|
||||
s.Index?.toString() === subtitleIndex &&
|
||||
(s.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
s.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
s.DeliveryMethod === SubtitleDeliveryMethod.External),
|
||||
) || subtitleIndex === "-1"
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
/** Check if subtitle is image-based (PGS, VOBSUB, etc.) */
|
||||
const isImageBased = (sub: MediaStream): boolean =>
|
||||
sub.IsTextSubtitleStream === false;
|
||||
|
||||
/**
|
||||
* Check if the currently selected subtitle is image-based.
|
||||
* Used to determine if we need to refresh the player when changing subs.
|
||||
*/
|
||||
const isCurrentSubImageBased = useMemo(() => {
|
||||
if (subtitleIndex === "-1") return false;
|
||||
const currentSub = allSubs.find(
|
||||
(s) => s.Index?.toString() === subtitleIndex,
|
||||
);
|
||||
return currentSub ? isImageBased(currentSub) : false;
|
||||
}, [allSubs, subtitleIndex]);
|
||||
|
||||
const setPlayerParams = ({
|
||||
chosenAudioIndex = audioIndex,
|
||||
chosenSubtitleIndex = subtitleIndex,
|
||||
}: {
|
||||
chosenAudioIndex?: string;
|
||||
chosenSubtitleIndex?: string;
|
||||
/**
|
||||
* Refresh the player with new parameters.
|
||||
* This triggers Jellyfin to re-process the stream (e.g., burn in image subs).
|
||||
*/
|
||||
const replacePlayer = (params: {
|
||||
audioIndex?: string;
|
||||
subtitleIndex?: string;
|
||||
}) => {
|
||||
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId ?? "",
|
||||
audioIndex: chosenAudioIndex,
|
||||
subtitleIndex: chosenSubtitleIndex,
|
||||
audioIndex: params.audioIndex ?? audioIndex,
|
||||
subtitleIndex: params.subtitleIndex ?? subtitleIndex,
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue,
|
||||
playbackPosition: playbackPosition,
|
||||
}).toString();
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
};
|
||||
|
||||
const setTrackParams = (
|
||||
_type: "subtitle",
|
||||
index: number,
|
||||
serverIndex: number,
|
||||
) => {
|
||||
// If we're transcoding and we're going from a image based subtitle
|
||||
// to a text based subtitle, we need to change the player params.
|
||||
|
||||
const shouldChangePlayerParams =
|
||||
mediaSource?.TranscodingUrl && !onTextBasedSubtitle;
|
||||
|
||||
console.log("Set player params", index, serverIndex);
|
||||
if (shouldChangePlayerParams) {
|
||||
setPlayerParams({
|
||||
chosenSubtitleIndex: serverIndex.toString(),
|
||||
});
|
||||
return;
|
||||
/**
|
||||
* Determine if a subtitle is available in MPV's track list.
|
||||
*
|
||||
* A subtitle is in MPV if:
|
||||
* - Delivery is Embed/Hls/External AND not an image-based sub during transcode
|
||||
*/
|
||||
const isSubtitleInMpv = (sub: MediaStream): boolean => {
|
||||
// During transcoding, image-based subs are burned in, not in MPV
|
||||
if (isTranscoding && isImageBased(sub)) {
|
||||
return false;
|
||||
}
|
||||
setSubtitleTrack?.(serverIndex);
|
||||
router.setParams({
|
||||
subtitleIndex: serverIndex.toString(),
|
||||
});
|
||||
|
||||
// Embed/Hls/External methods mean the sub is loaded into MPV
|
||||
return (
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch tracks when track count changes
|
||||
useEffect(() => {
|
||||
if (trackCount === 0) return;
|
||||
|
||||
const fetchTracks = async () => {
|
||||
if (getSubtitleTracks) {
|
||||
let subtitleData: SubtitleTrack[] | null = null;
|
||||
try {
|
||||
subtitleData = await getSubtitleTracks();
|
||||
console.log("subtitleData", subtitleData);
|
||||
} catch (error) {
|
||||
console.log("[VideoContext] Failed to get subtitle tracks:", error);
|
||||
return;
|
||||
}
|
||||
const [subtitleData, audioData] = await Promise.all([
|
||||
playerControls.getSubtitleTracks().catch(() => null),
|
||||
playerControls.getAudioTracks().catch(() => null),
|
||||
]);
|
||||
|
||||
let embedSubIndex = 1;
|
||||
const processedSubs: Track[] = allSubs?.map((sub) => {
|
||||
/** A boolean value determining if we should increment the embedSubIndex */
|
||||
const shouldIncrement =
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||
/** The index of subtitle inside MPV Player itself */
|
||||
const mpvIndex = subtitleData?.at(embedSubIndex)?.id ?? -1;
|
||||
if (shouldIncrement) embedSubIndex++;
|
||||
return {
|
||||
name: sub.DisplayTitle || "Undefined Subtitle",
|
||||
index: sub.Index ?? -1,
|
||||
setTrack: () =>
|
||||
shouldIncrement
|
||||
? setTrackParams("subtitle", mpvIndex, sub.Index ?? -1)
|
||||
: setPlayerParams({
|
||||
chosenSubtitleIndex: sub.Index?.toString(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
// Process subtitles - map Jellyfin indices to MPV track IDs
|
||||
let mpvIndex = 0; // MPV track index counter (only incremented for subs in MPV)
|
||||
|
||||
// Step 3: Restore the original order
|
||||
const subtitles: Track[] = processedSubs.sort(
|
||||
(a, b) => a.index - b.index,
|
||||
);
|
||||
const subs: Track[] = allSubs.map((sub) => {
|
||||
const inMpv = isSubtitleInMpv(sub);
|
||||
|
||||
// Add a "Disable Subtitles" option
|
||||
subtitles.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () =>
|
||||
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
||||
? setTrackParams("subtitle", -1, -1)
|
||||
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
||||
});
|
||||
setSubtitleTracks(subtitles);
|
||||
}
|
||||
// Get MPV track ID: only if this sub is actually in MPV's track list
|
||||
const mpvId = inMpv
|
||||
? ((subtitleData as SubtitleTrack[])?.[mpvIndex++]?.id ?? -1)
|
||||
: -1;
|
||||
|
||||
return {
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1, // Jellyfin server-side index
|
||||
mpvIndex: mpvId, // MPV track ID (-1 if not in MPV)
|
||||
setTrack: () => {
|
||||
// Case 1: Transcoding + switching to/from image-based sub
|
||||
// Need to refresh player so Jellyfin burns in the new sub
|
||||
if (
|
||||
isTranscoding &&
|
||||
(isImageBased(sub) || isCurrentSubImageBased)
|
||||
) {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: Subtitle is in MPV - just switch tracks
|
||||
if (inMpv && mpvId !== -1) {
|
||||
playerControls.setSubtitleTrack(mpvId);
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 3: Fallback - refresh player
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Add "Disable" option at the beginning
|
||||
subs.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () => {
|
||||
// If currently using image-based sub during transcode, need to refresh
|
||||
if (isTranscoding && isCurrentSubImageBased) {
|
||||
replacePlayer({ subtitleIndex: "-1" });
|
||||
} else {
|
||||
playerControls.setSubtitleTrack(-1);
|
||||
router.setParams({ subtitleIndex: "-1" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Process audio tracks
|
||||
const audio: Track[] = allAudio.map((a, idx) => ({
|
||||
name: a.DisplayTitle || "Unknown",
|
||||
index: a.Index ?? -1,
|
||||
setTrack: () => {
|
||||
// Transcoding: need full player refresh to change audio stream
|
||||
if (isTranscoding) {
|
||||
replacePlayer({ audioIndex: String(a.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct play: just switch audio track in MPV
|
||||
const mpvId = (audioData as AudioTrack[])?.[idx]?.id ?? idx + 1;
|
||||
playerControls.setAudioTrack(mpvId);
|
||||
router.setParams({ audioIndex: String(a.Index) });
|
||||
},
|
||||
}));
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
setAudioTracks(audio);
|
||||
};
|
||||
|
||||
fetchTracks();
|
||||
}, [isVideoLoaded, getSubtitleTracks]);
|
||||
|
||||
// Fetch audio tracks
|
||||
useEffect(() => {
|
||||
const fetchAudioTracks = async () => {
|
||||
if (getAudioTracks) {
|
||||
let audioData: AudioTrack[] | null = null;
|
||||
try {
|
||||
audioData = await getAudioTracks();
|
||||
console.log("audioData", audioData);
|
||||
} catch (error) {
|
||||
console.log("[VideoContext] Failed to get audio tracks:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
|
||||
let embedAudioIndex = 0;
|
||||
const processedAudio: Track[] = allAudio?.map((audio) => {
|
||||
const mpvIndex = audioData?.at(embedAudioIndex)?.id ?? 1;
|
||||
embedAudioIndex++;
|
||||
return {
|
||||
name: audio.DisplayTitle || "Undefined Audio",
|
||||
index: audio.Index ?? -1,
|
||||
setTrack: () => {
|
||||
setAudioTrack?.(mpvIndex);
|
||||
router.setParams({
|
||||
audioIndex: audio.Index?.toString() ?? "0",
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
setAudioTracks(processedAudio);
|
||||
}
|
||||
};
|
||||
fetchAudioTracks();
|
||||
}, [isVideoLoaded, getAudioTracks]);
|
||||
}, [trackCount, mediaSource]);
|
||||
|
||||
return (
|
||||
<VideoContext.Provider
|
||||
value={{
|
||||
subtitleTracks,
|
||||
audioTracks,
|
||||
setSubtitleTrack,
|
||||
setSubtitleURL,
|
||||
}}
|
||||
>
|
||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||
{children}
|
||||
</VideoContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVideoContext = () => {
|
||||
const context = useContext(VideoContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useVideoContext must be used within a VideoProvider");
|
||||
}
|
||||
return context;
|
||||
const ctx = useContext(VideoContext);
|
||||
if (!ctx)
|
||||
throw new Error("useVideoContext must be used within VideoProvider");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
@@ -7,17 +7,12 @@ import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { usePlayerContext } from "../contexts/PlayerContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
|
||||
const DropdownView = () => {
|
||||
const videoContext = useVideoContext();
|
||||
const { subtitleTracks, audioTracks } = videoContext;
|
||||
const ControlContext = useControlContext();
|
||||
const [item, mediaSource] = [
|
||||
ControlContext?.item,
|
||||
ControlContext?.mediaSource,
|
||||
];
|
||||
const { subtitleTracks, audioTracks } = useVideoContext();
|
||||
const { item, mediaSource } = usePlayerContext();
|
||||
const router = useRouter();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||
|
||||
Reference in New Issue
Block a user