feat: MPV player for both Android and iOS with added HW decoding PiP (with subtitles) (#1332)

Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
This commit is contained in:
Fredrik Burmester
2026-01-10 19:35:27 +01:00
committed by GitHub
parent df2f44e086
commit f1575ca48b
98 changed files with 3257 additions and 7448 deletions

View File

@@ -9,19 +9,15 @@ import React, {
useContext,
useMemo,
} from "react";
import type { SfPlayerViewRef, VlcPlayerViewRef } from "@/modules";
import type { MpvPlayerViewRef } from "@/modules";
import type { DownloadedItem } from "@/providers/Downloads/types";
// Union type for both player refs
type PlayerRef = SfPlayerViewRef | VlcPlayerViewRef;
interface PlayerContextProps {
playerRef: MutableRefObject<PlayerRef | null>;
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
item: BaseItemDto;
mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean;
tracksReady: boolean;
useVlcPlayer: boolean;
offline: boolean;
downloadedItem: DownloadedItem | null;
}
@@ -30,12 +26,11 @@ const PlayerContext = createContext<PlayerContextProps | undefined>(undefined);
interface PlayerProviderProps {
children: ReactNode;
playerRef: MutableRefObject<PlayerRef | null>;
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
item: BaseItemDto;
mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean;
tracksReady: boolean;
useVlcPlayer: boolean;
offline?: boolean;
downloadedItem?: DownloadedItem | null;
}
@@ -47,7 +42,6 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
mediaSource,
isVideoLoaded,
tracksReady,
useVlcPlayer,
offline = false,
downloadedItem = null,
}) => {
@@ -58,7 +52,6 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
mediaSource,
isVideoLoaded,
tracksReady,
useVlcPlayer,
offline,
downloadedItem,
}),
@@ -68,7 +61,6 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
mediaSource,
isVideoLoaded,
tracksReady,
useVlcPlayer,
offline,
downloadedItem,
],
@@ -87,30 +79,26 @@ export const usePlayerContext = () => {
return context;
};
// Player controls hook - supports both SfPlayer (iOS) and VlcPlayer (Android)
// Player controls hook - MPV player only
export const usePlayerControls = () => {
const { playerRef } = usePlayerContext();
// Helper to get SfPlayer-specific ref (for iOS-only features)
const getSfRef = () => playerRef.current as SfPlayerViewRef | null;
return {
// Subtitle controls (both players support these, but with different interfaces)
// Subtitle controls
getSubtitleTracks: async () => {
return playerRef.current?.getSubtitleTracks?.() ?? null;
},
setSubtitleTrack: (trackId: number) => {
playerRef.current?.setSubtitleTrack?.(trackId);
},
// iOS only (SfPlayer)
disableSubtitles: () => {
getSfRef()?.disableSubtitles?.();
playerRef.current?.disableSubtitles?.();
},
addSubtitleFile: (url: string, select = true) => {
getSfRef()?.addSubtitleFile?.(url, select);
playerRef.current?.addSubtitleFile?.(url, select);
},
// Audio controls (both players)
// Audio controls
getAudioTracks: async () => {
return playerRef.current?.getAudioTracks?.() ?? null;
},
@@ -118,26 +106,25 @@ export const usePlayerControls = () => {
playerRef.current?.setAudioTrack?.(trackId);
},
// Playback controls (both players)
// Playback controls
play: () => playerRef.current?.play?.(),
pause: () => playerRef.current?.pause?.(),
seekTo: (position: number) => playerRef.current?.seekTo?.(position),
// iOS only (SfPlayer)
seekBy: (offset: number) => getSfRef()?.seekBy?.(offset),
setSpeed: (speed: number) => getSfRef()?.setSpeed?.(speed),
seekBy: (offset: number) => playerRef.current?.seekBy?.(offset),
setSpeed: (speed: number) => playerRef.current?.setSpeed?.(speed),
// Subtitle positioning - iOS only (SfPlayer)
setSubtitleScale: (scale: number) => getSfRef()?.setSubtitleScale?.(scale),
// Subtitle positioning
setSubtitleScale: (scale: number) =>
playerRef.current?.setSubtitleScale?.(scale),
setSubtitlePosition: (position: number) =>
getSfRef()?.setSubtitlePosition?.(position),
playerRef.current?.setSubtitlePosition?.(position),
setSubtitleMarginY: (margin: number) =>
getSfRef()?.setSubtitleMarginY?.(margin),
playerRef.current?.setSubtitleMarginY?.(margin),
setSubtitleFontSize: (size: number) =>
getSfRef()?.setSubtitleFontSize?.(size),
playerRef.current?.setSubtitleFontSize?.(size),
// PiP (both players)
// PiP
startPictureInPicture: () => playerRef.current?.startPictureInPicture?.(),
// iOS only (SfPlayer)
stopPictureInPicture: () => getSfRef()?.stopPictureInPicture?.(),
stopPictureInPicture: () => playerRef.current?.stopPictureInPicture?.(),
};
};

View File

@@ -8,7 +8,7 @@
* ============================================================================
*
* - Jellyfin is source of truth for subtitle list (embedded + external)
* - KSPlayer only knows about:
* - MPV only knows about:
* - Embedded subs it finds in the video stream
* - External subs we explicitly add via addSubtitleFile()
* - UI shows Jellyfin's complete list
@@ -24,8 +24,8 @@
* - Value of -1 means disabled/none
*
* 2. MPV INDEX (track.mpvIndex)
* - KSPlayer's internal track ID
* - KSPlayer orders tracks as: [all embedded, then all external]
* - MPV's internal track ID
* - MPV orders tracks as: [all embedded, then all external]
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
*
@@ -34,15 +34,15 @@
* ============================================================================
*
* Embedded (DeliveryMethod.Embed):
* - Already in KSPlayer's track list
* - Already in MPV's track list
* - Select via setSubtitleTrack(mpvId)
*
* External (DeliveryMethod.External):
* - Loaded into KSPlayer's srtControl on video start
* - Loaded into MPV on video start
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
*
* Image-based during transcoding:
* - Burned into video by Jellyfin, not in KSPlayer
* - Burned into video by Jellyfin, not in MPV
* - Requires replacePlayer() to change
*/
@@ -57,7 +57,7 @@ import {
useMemo,
useState,
} from "react";
import type { SfAudioTrack, TrackInfo } from "@/modules";
import type { MpvAudioTrack } from "@/modules";
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
import type { Track } from "../types";
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
@@ -75,7 +75,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const { tracksReady, mediaSource, useVlcPlayer, offline, downloadedItem } =
const { tracksReady, mediaSource, offline, downloadedItem } =
usePlayerContext();
const playerControls = usePlayerControls();
@@ -149,7 +149,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
{
name: downloadedTrack.DisplayTitle || "Audio",
index: downloadedTrack.Index ?? 0,
mpvIndex: useVlcPlayer ? 0 : 1, // Only track in file
mpvIndex: 1, // Only track in file (MPV uses 1-based indexing)
setTrack: () => {
// Track is already selected (only one available)
router.setParams({ audioIndex: String(downloadedTrack.Index) });
@@ -212,99 +212,12 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
return;
}
// For VLC player, use simpler track handling with server indices
if (useVlcPlayer) {
// Get VLC track info (VLC returns TrackInfo[] with 'index' property)
const vlcSubtitleData = (await playerControls
.getSubtitleTracks()
.catch(() => null)) as TrackInfo[] | null;
const vlcAudioData = (await playerControls
.getAudioTracks()
.catch(() => null)) as TrackInfo[] | null;
// VLC reverses HLS subtitles during transcoding
let vlcSubs: TrackInfo[] = vlcSubtitleData ? [...vlcSubtitleData] : [];
if (isTranscoding && vlcSubs.length > 1) {
vlcSubs = [vlcSubs[0], ...vlcSubs.slice(1).reverse()];
}
// Build subtitle tracks for VLC
const subs: Track[] = [];
let vlcSubIndex = 1; // VLC track indices start at 1 (0 is usually "Disable")
for (const sub of allSubs) {
const isTextBased =
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
// Get VLC's internal index for this track
const vlcTrackIndex = vlcSubs[vlcSubIndex]?.index ?? -1;
if (isTextBased) vlcSubIndex++;
// For image-based subs during transcoding, or non-text subs, use replacePlayer
const needsPlayerRefresh =
(isTranscoding && isImageBasedSubtitle(sub)) || !isTextBased;
subs.push({
name: sub.DisplayTitle || "Unknown",
index: sub.Index ?? -1,
mpvIndex: vlcTrackIndex,
setTrack: () => {
if (needsPlayerRefresh) {
replacePlayer({ subtitleIndex: String(sub.Index) });
} else if (vlcTrackIndex !== -1) {
playerControls.setSubtitleTrack(vlcTrackIndex);
router.setParams({ subtitleIndex: String(sub.Index) });
} else {
replacePlayer({ subtitleIndex: String(sub.Index) });
}
},
});
}
// Add "Disable" option
subs.unshift({
name: "Disable",
index: -1,
mpvIndex: -1,
setTrack: () => {
playerControls.setSubtitleTrack(-1);
router.setParams({ subtitleIndex: "-1" });
},
});
// Build audio tracks for VLC
const vlcAudio: TrackInfo[] = vlcAudioData ? [...vlcAudioData] : [];
const audio: Track[] = allAudio.map((a, idx) => {
const vlcTrackIndex = vlcAudio[idx + 1]?.index ?? idx;
return {
name: a.DisplayTitle || "Unknown",
index: a.Index ?? -1,
mpvIndex: vlcTrackIndex,
setTrack: () => {
if (isTranscoding) {
replacePlayer({ audioIndex: String(a.Index) });
} else {
playerControls.setAudioTrack(vlcTrackIndex);
router.setParams({ audioIndex: String(a.Index) });
}
},
};
});
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
setAudioTracks(audio);
return;
}
// KSPlayer track handling (original logic)
// MPV track handling
const audioData = await playerControls.getAudioTracks().catch(() => null);
const playerAudio = (audioData as SfAudioTrack[]) ?? [];
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
// Separate embedded vs external subtitles from Jellyfin's list
// KSPlayer orders tracks as: [all embedded, then all external]
// MPV orders tracks as: [all embedded, then all external]
const embeddedSubs = allSubs.filter(
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
);
@@ -312,7 +225,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
);
// Count embedded subs that will be in KSPlayer
// Count embedded subs that will be in MPV
// (excludes image-based subs during transcoding as they're burned in)
const embeddedInPlayer = embeddedSubs.filter(
(s) => !isTranscoding || !isImageBasedSubtitle(s),
@@ -339,8 +252,8 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
continue;
}
// Calculate KSPlayer track ID based on type
// KSPlayer IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
// Calculate MPV track ID based on type
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
let mpvId = -1;
if (isEmbedded) {
@@ -428,7 +341,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
};
fetchTracks();
}, [tracksReady, mediaSource, useVlcPlayer, offline, downloadedItem]);
}, [tracksReady, mediaSource, offline, downloadedItem]);
return (
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>