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

@@ -134,18 +134,16 @@ export enum VideoPlayer {
MPV = 0,
}
// iOS video player selection
export enum VideoPlayerIOS {
KSPlayer = "ksplayer",
VLC = "vlc",
}
// Audio transcoding mode - controls how surround audio is handled
// This controls server-side transcoding behavior for audio streams.
// MPV decodes via FFmpeg and supports most formats, but mobile devices
// can't passthrough to external receivers, so this primarily affects
// bandwidth usage and server load.
export enum AudioTranscodeMode {
Auto = "auto", // Platform/player defaults (recommended)
Auto = "auto", // Platform defaults (recommended)
ForceStereo = "stereo", // Always transcode to stereo
Allow51 = "5.1", // Allow up to 5.1, transcode 7.1+
AllowAll = "passthrough", // Direct play all (for external DAC users)
AllowAll = "passthrough", // Direct play all audio formats
}
export type Settings = {
@@ -192,20 +190,6 @@ export type Settings = {
mpvSubtitleAlignX?: "left" | "center" | "right";
mpvSubtitleAlignY?: "top" | "center" | "bottom";
mpvSubtitleFontSize?: number;
// KSPlayer settings
ksHardwareDecode: boolean;
ksSubtitleColor: string;
ksSubtitleBackgroundColor: string;
ksSubtitleFontName: string;
// VLC subtitle settings
vlcTextColor?: string;
vlcBackgroundColor?: string;
vlcBackgroundOpacity?: number;
vlcOutlineColor?: string;
vlcOutlineOpacity?: number;
vlcOutlineThickness?: "None" | "Thin" | "Normal" | "Thick";
vlcIsBold?: boolean;
vlcSubtitleMargin?: number;
// Gesture controls
enableHorizontalSwipeSkip: boolean;
enableLeftSideBrightnessSwipe: boolean;
@@ -215,8 +199,6 @@ export type Settings = {
usePopularPlugin: boolean;
showLargeHomeCarousel: boolean;
mergeNextUpAndContinueWatching: boolean;
// iOS video player selection
videoPlayerIOS: VideoPlayerIOS;
// Appearance
hideRemoteSessionButton: boolean;
hideWatchlistsTab: boolean;
@@ -292,20 +274,6 @@ export const defaultValues: Settings = {
mpvSubtitleAlignX: undefined,
mpvSubtitleAlignY: undefined,
mpvSubtitleFontSize: undefined,
// KSPlayer defaults
ksHardwareDecode: true,
ksSubtitleColor: "#FFFFFF",
ksSubtitleBackgroundColor: "#00000080",
ksSubtitleFontName: "System",
// VLC subtitle defaults
vlcTextColor: "White",
vlcBackgroundColor: "Black",
vlcBackgroundOpacity: 128,
vlcOutlineColor: "Black",
vlcOutlineOpacity: 255,
vlcOutlineThickness: "Normal",
vlcIsBold: false,
vlcSubtitleMargin: 40,
// Gesture controls
enableHorizontalSwipeSkip: true,
enableLeftSideBrightnessSwipe: true,
@@ -315,8 +283,6 @@ export const defaultValues: Settings = {
usePopularPlugin: true,
showLargeHomeCarousel: false,
mergeNextUpAndContinueWatching: false,
// iOS video player selection - default to VLC
videoPlayerIOS: VideoPlayerIOS.VLC,
// Appearance
hideRemoteSessionButton: false,
hideWatchlistsTab: false,

View File

@@ -1,7 +1,7 @@
import type { Api } from "@jellyfin/sdk";
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import native from "@/utils/profiles/native";
import trackPlayerProfile from "@/utils/profiles/trackplayer";
export interface AudioStreamResult {
url: string;
@@ -26,7 +26,7 @@ export const getAudioStreamUrl = async (
method: "POST",
data: {
userId,
deviceProfile: native,
deviceProfile: trackPlayerProfile,
startTimeTicks: 0,
isPlayback: true,
autoOpenLiveStream: true,

View File

@@ -4,7 +4,10 @@ import type {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Bitrate } from "@/components/BitrateSelector";
import { generateDeviceProfile } from "@/utils/profiles/native";
import {
type AudioTranscodeModeType,
generateDeviceProfile,
} from "@/utils/profiles/native";
import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl";
export const getDownloadUrl = async ({
@@ -16,6 +19,7 @@ export const getDownloadUrl = async ({
audioStreamIndex,
subtitleStreamIndex,
deviceId,
audioMode = "auto",
}: {
api: Api;
item: BaseItemDto;
@@ -25,6 +29,7 @@ export const getDownloadUrl = async ({
audioStreamIndex: number;
subtitleStreamIndex: number;
deviceId: string;
audioMode?: AudioTranscodeModeType;
}): Promise<{
url: string | null;
mediaSource: MediaSourceInfo | null;
@@ -39,7 +44,7 @@ export const getDownloadUrl = async ({
audioStreamIndex,
subtitleStreamIndex,
deviceId,
deviceProfile: generateDeviceProfile(),
deviceProfile: generateDeviceProfile({ audioMode }),
});
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
@@ -59,6 +64,7 @@ export const getDownloadUrl = async ({
maxStreamingBitrate: maxBitrate.value,
audioStreamIndex,
subtitleStreamIndex,
audioMode,
});
return {

View File

@@ -5,7 +5,8 @@ import type {
} from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import download from "@/utils/profiles/download";
import { generateDownloadProfile } from "@/utils/profiles/download";
import type { AudioTranscodeModeType } from "@/utils/profiles/native";
interface StreamResult {
url: string;
@@ -265,6 +266,7 @@ export const getDownloadStreamUrl = async ({
subtitleStreamIndex = undefined,
mediaSourceId,
deviceId,
audioMode = "auto",
}: {
api: Api | null | undefined;
item: BaseItemDto | null | undefined;
@@ -274,6 +276,7 @@ export const getDownloadStreamUrl = async ({
subtitleStreamIndex?: number;
mediaSourceId?: string | null;
deviceId?: string | null;
audioMode?: AudioTranscodeModeType;
}): Promise<{
url: string | null;
sessionId: string | null;
@@ -292,7 +295,7 @@ export const getDownloadStreamUrl = async ({
method: "POST",
data: {
userId,
deviceProfile: download,
deviceProfile: generateDownloadProfile(audioMode),
subtitleStreamIndex,
startTimeTicks: 0,
isPlayback: true,

View File

@@ -3,111 +3,79 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from "../../constants/MediaTypes";
import { generateDeviceProfile } from "./native";
/**
* Device profile for Native video player
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
*/
export default {
Name: "1. Vlc Player",
MaxStaticBitrate: 20_000_000,
MaxStreamingBitrate: 20_000_000,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
Type: MediaTypes.Video,
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
VideoCodec:
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec:
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
},
],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3",
CopyTimestamps: false,
EnableSubtitlesInManifest: true,
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [
// Official foramts
{ Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Encode" },
/**
* Download-specific subtitle profiles.
* These are more permissive than streaming profiles since we can embed subtitles.
*/
const downloadSubtitleProfiles = [
// Official formats
{ Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Encode" },
{ Format: "srt", Method: "Encode" },
{ Format: "subrip", Method: "Encode" },
{ Format: "ttml", Method: "Encode" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Encode" },
{ Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Encode" },
// Other formats
{ Format: "microdvd", Method: "Encode" },
{ Format: "mov_text", Method: "Encode" },
{ Format: "mpl2", Method: "Encode" },
{ Format: "pjs", Method: "Encode" },
{ Format: "realtext", Method: "Encode" },
{ Format: "scc", Method: "Encode" },
{ Format: "smi", Method: "Encode" },
{ Format: "stl", Method: "Encode" },
{ Format: "sub", Method: "Encode" },
{ Format: "subviewer", Method: "Encode" },
{ Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Encode" },
{ Format: "vplayer", Method: "Encode" },
{ Format: "xsub", Method: "Encode" },
];
{ Format: "srt", Method: "Encode" },
/**
* Generates a device profile optimized for downloads.
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
*
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
* @returns {Object} Jellyfin device profile for downloads
*/
export const generateDownloadProfile = (audioMode = "auto") => {
// Get the base profile with proper audio codec configuration
const baseProfile = generateDeviceProfile({ audioMode });
{ Format: "subrip", Method: "Encode" },
{ Format: "ttml", Method: "Encode" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Encode" },
{ Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Encode" },
// Other formats
{ Format: "microdvd", Method: "Encode" },
{ Format: "mov_text", Method: "Encode" },
{ Format: "mpl2", Method: "Encode" },
{ Format: "pjs", Method: "Encode" },
{ Format: "realtext", Method: "Encode" },
{ Format: "scc", Method: "Encode" },
{ Format: "smi", Method: "Encode" },
{ Format: "stl", Method: "Encode" },
{ Format: "sub", Method: "Encode" },
{ Format: "subviewer", Method: "Encode" },
{ Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Encode" },
{ Format: "vplayer", Method: "Encode" },
{ Format: "xsub", Method: "Encode" },
],
// Override with download-specific settings
return {
...baseProfile,
Name: "1. MPV Download",
// Limit bitrate for downloads (20 Mbps)
MaxStaticBitrate: 20_000_000,
MaxStreamingBitrate: 20_000_000,
// Use download-specific subtitle profiles
SubtitleProfiles: downloadSubtitleProfiles,
// Update transcoding profiles with download-specific settings
TranscodingProfiles: baseProfile.TranscodingProfiles.map((profile) => {
if (profile.Type === "Video") {
return {
...profile,
CopyTimestamps: false,
EnableSubtitlesInManifest: true,
};
}
return profile;
}),
};
};
// Default export for backward compatibility
export default generateDownloadProfile();

View File

@@ -5,7 +5,7 @@
*/
export type PlatformType = "ios" | "android";
export type PlayerType = "vlc" | "ksplayer";
export type PlayerType = "mpv";
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
export interface ProfileOptions {
@@ -18,3 +18,6 @@ export interface ProfileOptions {
}
export function generateDeviceProfile(options?: ProfileOptions): any;
declare const _default: any;
export default _default;

View File

@@ -9,22 +9,22 @@ import { getSubtitleProfiles } from "./subtitles";
/**
* @typedef {"ios" | "android"} PlatformType
* @typedef {"vlc" | "ksplayer"} PlayerType
* @typedef {"mpv"} PlayerType
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
*
* @typedef {Object} ProfileOptions
* @property {PlatformType} [platform] - Target platform
* @property {PlayerType} [player] - Video player being used
* @property {PlayerType} [player] - Video player being used (MPV only)
* @property {AudioTranscodeModeType} [audioMode] - Audio transcoding mode
*/
/**
* Audio profiles for react-native-track-player based on platform capabilities.
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
* Audio direct play profiles for standalone audio items in MPV player.
* These define which audio file formats can be played directly without transcoding.
*/
const getAudioDirectPlayProfile = (platform) => {
if (platform === "ios") {
// iOS AVPlayer supported formats
// iOS audio formats supported by MPV
return {
Type: MediaTypes.Audio,
Container: "mp3,m4a,aac,flac,alac,wav,aiff,caf",
@@ -32,7 +32,7 @@ const getAudioDirectPlayProfile = (platform) => {
};
}
// Android ExoPlayer supported formats
// Android audio formats supported by MPV
return {
Type: MediaTypes.Audio,
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
@@ -40,16 +40,20 @@ const getAudioDirectPlayProfile = (platform) => {
};
};
/**
* Audio codec profiles for standalone audio items in MPV player.
* These define codec constraints for audio file playback.
*/
const getAudioCodecProfile = (platform) => {
if (platform === "ios") {
// iOS AVPlayer codec constraints
// iOS audio codec constraints for MPV
return {
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,pcm",
};
}
// Android ExoPlayer codec constraints
// Android audio codec constraints for MPV
return {
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,vorbis,opus,pcm",
@@ -57,72 +61,61 @@ const getAudioCodecProfile = (platform) => {
};
/**
* Gets the video audio codec configuration based on platform, player, and audio mode.
* Gets the video audio codec configuration based on platform and audio mode.
*
* Key insight: VLC handles AC3/EAC3/DTS downmixing fine.
* Only TrueHD and DTS-HD MA (lossless 7.1) cause issues on mobile devices
* because VLC's internal downmixing from 7.1 to stereo fails on some Android audio pipelines.
* MPV (via FFmpeg) can decode all audio codecs including TrueHD and DTS-HD MA.
* The audioMode setting only controls the maximum channel count - MPV will
* decode and downmix as needed.
*
* @param {PlatformType} platform
* @param {PlayerType} player
* @param {AudioTranscodeModeType} audioMode
* @returns {{ directPlayCodec: string, maxAudioChannels: string }}
*/
const getVideoAudioCodecs = (platform, player, audioMode) => {
// Base codecs that work everywhere
const getVideoAudioCodecs = (platform, audioMode) => {
// Base codecs
const baseCodecs = "aac,mp3,flac,opus,vorbis";
// Surround codecs that VLC handles well (downmixes properly)
// Surround codecs
const surroundCodecs = "ac3,eac3,dts";
// Lossless HD codecs that cause issues with VLC's downmixing on mobile
// Lossless HD codecs - MPV decodes these and downmixes as needed
const losslessHdCodecs = "truehd";
// Platform-specific codecs
const platformCodecs = platform === "ios" ? "alac,wma" : "wma";
// Handle explicit user settings first
// MPV can decode all codecs - only channel count varies by mode
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
switch (audioMode) {
case "stereo":
// Force stereo transcoding - only allow basic codecs
// Limit to 2 channels - MPV will decode and downmix
return {
directPlayCodec: `${baseCodecs},${platformCodecs}`,
directPlayCodec: allCodecs,
maxAudioChannels: "2",
};
case "5.1":
// Allow up to 5.1 - include surround codecs but not lossless HD
// Limit to 6 channels
return {
directPlayCodec: `${baseCodecs},${surroundCodecs},${platformCodecs}`,
directPlayCodec: allCodecs,
maxAudioChannels: "6",
};
case "passthrough":
// Allow all codecs - for users with external DAC/receiver
// Allow up to 8 channels - for external DAC/receiver setups
return {
directPlayCodec: `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`,
directPlayCodec: allCodecs,
maxAudioChannels: "8",
};
default:
// Auto mode: platform and player-specific defaults
break;
// Auto mode: default to 5.1 (6 channels)
return {
directPlayCodec: allCodecs,
maxAudioChannels: "6",
};
}
// Auto mode logic based on platform and player
if (player === "ksplayer" && platform === "ios") {
// KSPlayer on iOS handles all codecs well, including TrueHD
return {
directPlayCodec: `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`,
maxAudioChannels: "8",
};
}
// VLC on Android or iOS - don't include TrueHD (causes 7.1 downmix issues)
// DTS core is fine, VLC handles it well. Only lossless 7.1 formats are problematic.
return {
directPlayCodec: `${baseCodecs},${surroundCodecs},${platformCodecs}`,
maxAudioChannels: "6",
};
};
/**
@@ -133,22 +126,18 @@ const getVideoAudioCodecs = (platform, player, audioMode) => {
*/
export const generateDeviceProfile = (options = {}) => {
const platform = options.platform || Platform.OS;
const player = options.player || "vlc";
const audioMode = options.audioMode || "auto";
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
platform,
player,
audioMode,
);
const playerName = player === "ksplayer" ? "KSPlayer" : "VLC Player";
/**
* Device profile for Native video player
* Device profile for MPV player
*/
const profile = {
Name: `1. ${playerName}`,
Name: "1. MPV",
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
@@ -210,3 +199,6 @@ export const generateDeviceProfile = (options = {}) => {
return profile;
};
// Default export for backward compatibility
export default generateDeviceProfile();

19
utils/profiles/trackplayer.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export type PlatformType = "ios" | "android";
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
export function generateTrackPlayerProfile(
options?: TrackPlayerProfileOptions,
): any;
declare const _default: any;
export default _default;

View File

@@ -0,0 +1,95 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes";
/**
* @typedef {"ios" | "android"} PlatformType
*
* @typedef {Object} TrackPlayerProfileOptions
* @property {PlatformType} [platform] - Target platform
*/
/**
* Audio direct play profiles for react-native-track-player.
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
*
* @param {PlatformType} platform
*/
const getDirectPlayProfile = (platform) => {
if (platform === "ios") {
// iOS AVPlayer supported formats
return {
Type: MediaTypes.Audio,
Container: "mp3,m4a,aac,flac,alac,wav,aiff,caf",
AudioCodec: "mp3,aac,alac,flac,opus,pcm",
};
}
// Android ExoPlayer supported formats
return {
Type: MediaTypes.Audio,
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
AudioCodec: "mp3,aac,flac,vorbis,opus,pcm",
};
};
/**
* Audio codec profiles for react-native-track-player.
*
* @param {PlatformType} platform
*/
const getCodecProfile = (platform) => {
if (platform === "ios") {
// iOS AVPlayer codec constraints
return {
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,pcm",
};
}
// Android ExoPlayer codec constraints
return {
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,vorbis,opus,pcm",
};
};
/**
* Generates a device profile for music playback via react-native-track-player.
*
* This profile is specifically for standalone audio playback using:
* - AVPlayer on iOS
* - ExoPlayer on Android
*
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
* @returns {Object} Jellyfin device profile for track player
*/
export const generateTrackPlayerProfile = (options = {}) => {
const platform = options.platform || Platform.OS;
return {
Name: "Track Player",
MaxStaticBitrate: 320_000_000,
MaxStreamingBitrate: 320_000_000,
CodecProfiles: [getCodecProfile(platform)],
DirectPlayProfiles: [getDirectPlayProfile(platform)],
TranscodingProfiles: [
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [],
};
};
// Default export for convenience
export default generateTrackPlayerProfile();