This commit is contained in:
Alex Kim
2025-12-06 05:28:30 +11:00
parent c76d7eb877
commit bc78346760
10 changed files with 204 additions and 19 deletions

View File

@@ -539,11 +539,6 @@ export default function page() {
[playbackManager, item?.Id, progress],
);
const _allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle",
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
@@ -582,6 +577,14 @@ export default function page() {
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;
@@ -702,8 +705,10 @@ export default function page() {
seek={seek}
enableTrickplay={true}
getSubtitleTracks={getSubtitleTracks}
getAudioTracks={getAudioTracks}
offline={offline}
setSubtitleTrack={setSubtitleTrack}
setAudioTrack={setAudioTrack}
setSubtitleURL={setSubtitleURL}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}

View File

@@ -28,7 +28,7 @@ import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { MpvPlayerViewRef, SubtitleTrack } from "@/modules";
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";
@@ -71,8 +71,10 @@ interface Props {
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,8 +102,10 @@ export const Controls: FC<Props> = ({
mediaSource,
isVideoLoaded,
getSubtitleTracks,
getAudioTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
setVideoAspectRatio,
setVideoScaleFactor,
aspectRatio = "default",
@@ -501,7 +505,9 @@ export const Controls: FC<Props> = ({
previousItem={previousItem}
nextItem={nextItem}
getSubtitleTracks={getSubtitleTracks}
getAudioTracks={getAudioTracks}
setSubtitleTrack={setSubtitleTrack}
setAudioTrack={setAudioTrack}
setSubtitleURL={setSubtitleURL}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}

View File

@@ -35,7 +35,9 @@ interface HeaderControlsProps {
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;
@@ -57,7 +59,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
previousItem,
nextItem,
getSubtitleTracks,
getAudioTracks,
setSubtitleTrack,
setAudioTrack,
setSubtitleURL,
aspectRatio = "default",
scaleFactor = 1.0,
@@ -111,7 +115,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<VideoProvider
getSubtitleTracks={getSubtitleTracks}
getAudioTracks={getAudioTracks}
setSubtitleTrack={setSubtitleTrack}
setAudioTrack={setAudioTrack}
setSubtitleURL={setSubtitleURL}
>
<View pointerEvents='auto'>

View File

@@ -9,12 +9,13 @@ import {
useMemo,
useState,
} from "react";
import type { SubtitleTrack } from "@/modules";
import type { AudioTrack, SubtitleTrack } from "@/modules";
import type { Track } from "../types";
import { useControlContext } from "./ControlContext";
interface VideoContextProps {
subtitleTracks: Track[] | null;
audioTracks: Track[] | null;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
}
@@ -27,21 +28,29 @@ interface VideoProviderProps {
| (() => 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;
}
/**
* Video context provider for managing subtitle tracks.
s * Video context provider for managing subtitle and audio tracks.
* MPV player is used for all playback.
*/
export const VideoProvider: React.FC<VideoProviderProps> = ({
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;
@@ -122,6 +131,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
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;
@@ -169,10 +179,49 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
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]);
return (
<VideoContext.Provider
value={{
subtitleTracks,
audioTracks,
setSubtitleTrack,
setSubtitleURL,
}}

View File

@@ -10,6 +10,7 @@ export { default as BackgroundDownloader } from "./background-downloader";
// Type aliases for backward compatibility during migration
// These map old VLC type names to new MPV equivalents
export type {
AudioTrack,
MpvPlayerViewProps,
MpvPlayerViewRef,
OnErrorEventPayload,

View File

@@ -1,10 +1,3 @@
//
// MPVSoftwareRenderer.swift
// test
//
// Created by Francesco on 28/09/25.
//
import UIKit
import Libmpv
import CoreMedia
@@ -115,9 +108,8 @@ final class MPVSoftwareRenderer {
setOption(name: "demuxer-readahead-secs", value: "20")
// Subtitle options - blend into video for software renderer
setOption(name: "blend-subtitles", value: "video")
setOption(name: "sub-visibility", value: "yes")
setOption(name: "osd-level", value: "0")
setOption(name: "sub-auto", value: "yes")
setOption(name: "subs-fallback", value: "yes")
let initStatus = mpv_initialize(handle)
guard initStatus >= 0 else {
@@ -979,11 +971,15 @@ final class MPVSoftwareRenderer {
// MARK: - Subtitle Controls
func getSubtitleTracks() -> [[String: Any]] {
guard let handle = mpv else { return [] }
guard let handle = mpv else {
Logger.shared.log("getSubtitleTracks: mpv handle is nil", type: "Warn")
return []
}
var tracks: [[String: Any]] = []
var trackCount: Int64 = 0
getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount)
Logger.shared.log("getSubtitleTracks: total track count = \(trackCount)", type: "Info")
for i in 0..<trackCount {
var trackType: String?
@@ -1010,13 +1006,16 @@ final class MPVSoftwareRenderer {
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
track["selected"] = selected != 0
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
tracks.append(track)
}
Logger.shared.log("getSubtitleTracks: returning \(tracks.count) subtitle tracks", type: "Info")
return tracks
}
func setSubtitleTrack(_ trackId: Int) {
Logger.shared.log("setSubtitleTrack: setting sid to \(trackId)", type: "Info")
setProperty(name: "sid", value: String(trackId))
}
@@ -1061,4 +1060,71 @@ final class MPVSoftwareRenderer {
func setSubtitleFontSize(_ size: Int) {
setProperty(name: "sub-font-size", value: String(size))
}
// MARK: - Audio Track Controls
func getAudioTracks() -> [[String: Any]] {
guard let handle = mpv else {
Logger.shared.log("getAudioTracks: mpv handle is nil", type: "Warn")
return []
}
var tracks: [[String: Any]] = []
var trackCount: Int64 = 0
getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount)
for i in 0..<trackCount {
var trackType: String?
if let typeStr = getStringProperty(handle: handle, name: "track-list/\(i)/type") {
trackType = typeStr
}
guard trackType == "audio" else { continue }
var trackId: Int64 = 0
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
var track: [String: Any] = ["id": Int(trackId)]
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {
track["title"] = title
}
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
track["lang"] = lang
}
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
track["codec"] = codec
}
var channels: Int64 = 0
getProperty(handle: handle, name: "track-list/\(i)/audio-channels", format: MPV_FORMAT_INT64, value: &channels)
if channels > 0 {
track["channels"] = Int(channels)
}
var selected: Int32 = 0
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
track["selected"] = selected != 0
Logger.shared.log("getAudioTracks: found audio track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
tracks.append(track)
}
Logger.shared.log("getAudioTracks: returning \(tracks.count) audio tracks", type: "Info")
return tracks
}
func setAudioTrack(_ trackId: Int) {
Logger.shared.log("setAudioTrack: setting aid to \(trackId)", type: "Info")
setProperty(name: "aid", value: String(trackId))
}
func getCurrentAudioTrack() -> Int {
guard let handle = mpv else { return 0 }
var aid: Int64 = 0
getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid)
return Int(aid)
}
}

View File

@@ -150,6 +150,19 @@ public class MpvPlayerModule: Module {
AsyncFunction("setSubtitleFontSize") { (view: MpvPlayerView, size: Int) in
view.setSubtitleFontSize(size)
}
// Audio track functions
AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]] in
return view.getAudioTracks()
}
AsyncFunction("setAudioTrack") { (view: MpvPlayerView, trackId: Int) in
view.setAudioTrack(trackId)
}
AsyncFunction("getCurrentAudioTrack") { (view: MpvPlayerView) -> Int in
return view.getCurrentAudioTrack()
}
// Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError")

View File

@@ -168,6 +168,20 @@ class MpvPlayerView: ExpoView {
renderer?.addSubtitleFile(url: url)
}
// MARK: - Audio Track Controls
func getAudioTracks() -> [[String: Any]] {
return renderer?.getAudioTracks() ?? []
}
func setAudioTrack(_ trackId: Int) {
renderer?.setAudioTrack(trackId)
}
func getCurrentAudioTrack() -> Int {
return renderer?.getCurrentAudioTrack() ?? 0
}
// MARK: - Subtitle Positioning
func setSubtitlePosition(_ position: Int) {

View File

@@ -56,6 +56,7 @@ export interface MpvPlayerViewRef {
stopPictureInPicture: () => Promise<void>;
isPictureInPictureSupported: () => Promise<boolean>;
isPictureInPictureActive: () => Promise<boolean>;
// Subtitle controls
getSubtitleTracks: () => Promise<SubtitleTrack[]>;
setSubtitleTrack: (trackId: number) => Promise<void>;
disableSubtitles: () => Promise<void>;
@@ -68,10 +69,24 @@ export interface MpvPlayerViewRef {
setSubtitleAlignX: (alignment: "left" | "center" | "right") => Promise<void>;
setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise<void>;
setSubtitleFontSize: (size: number) => Promise<void>;
// Audio controls
getAudioTracks: () => Promise<AudioTrack[]>;
setAudioTrack: (trackId: number) => Promise<void>;
getCurrentAudioTrack: () => Promise<number>;
}
export type SubtitleTrack = {
id: number;
title?: string;
lang?: string;
selected?: boolean;
};
export type AudioTrack = {
id: number;
title?: string;
lang?: string;
codec?: string;
channels?: number;
selected?: boolean;
};

View File

@@ -84,6 +84,16 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
setSubtitleFontSize: async (size: number) => {
await nativeRef.current?.setSubtitleFontSize(size);
},
// Audio controls
getAudioTracks: async () => {
return await nativeRef.current?.getAudioTracks();
},
setAudioTrack: async (trackId: number) => {
await nativeRef.current?.setAudioTrack(trackId);
},
getCurrentAudioTrack: async () => {
return await nativeRef.current?.getCurrentAudioTrack();
},
}));
return <NativeView ref={nativeRef} {...props} />;