mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-14 09:50:23 +01:00
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:
committed by
GitHub
parent
df2f44e086
commit
f1575ca48b
@@ -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?.(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user