diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index a70f9814..293ddc49 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -299,7 +299,11 @@ export default function page() { maxStreamingBitrate: bitrateValue, mediaSourceId: mediaSourceId, subtitleStreamIndex: subtitleIndex, - deviceProfile: generateDeviceProfile(), + deviceProfile: generateDeviceProfile({ + platform: Platform.OS as "ios" | "android", + player: useVlcPlayer ? "vlc" : "ksplayer", + audioMode: settings.audioTranscodeMode, + }), }); if (!res) return; const { mediaSource, sessionId, url } = res; diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index ef7b42f3..93a267bd 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -3,7 +3,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Platform, View, type ViewProps } from "react-native"; import { Switch } from "react-native-gesture-handler"; -import { useSettings } from "@/utils/atoms/settings"; +import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; @@ -54,6 +54,70 @@ export const AudioToggles: React.FC = ({ ...props }) => { ]; }, [cultures, settings?.defaultAudioLanguage, t, updateSettings]); + const audioTranscodeModeLabels: Record = { + [AudioTranscodeMode.Auto]: t("home.settings.audio.transcode_mode.auto"), + [AudioTranscodeMode.ForceStereo]: t( + "home.settings.audio.transcode_mode.stereo", + ), + [AudioTranscodeMode.Allow51]: t("home.settings.audio.transcode_mode.5_1"), + [AudioTranscodeMode.AllowAll]: t( + "home.settings.audio.transcode_mode.passthrough", + ), + }; + + const audioTranscodeModeOptions = useMemo( + () => [ + { + options: [ + { + type: "radio" as const, + label: t("home.settings.audio.transcode_mode.auto"), + value: AudioTranscodeMode.Auto, + selected: + settings?.audioTranscodeMode === AudioTranscodeMode.Auto || + !settings?.audioTranscodeMode, + onPress: () => + updateSettings({ audioTranscodeMode: AudioTranscodeMode.Auto }), + }, + { + type: "radio" as const, + label: t("home.settings.audio.transcode_mode.stereo"), + value: AudioTranscodeMode.ForceStereo, + selected: + settings?.audioTranscodeMode === AudioTranscodeMode.ForceStereo, + onPress: () => + updateSettings({ + audioTranscodeMode: AudioTranscodeMode.ForceStereo, + }), + }, + { + type: "radio" as const, + label: t("home.settings.audio.transcode_mode.5_1"), + value: AudioTranscodeMode.Allow51, + selected: + settings?.audioTranscodeMode === AudioTranscodeMode.Allow51, + onPress: () => + updateSettings({ + audioTranscodeMode: AudioTranscodeMode.Allow51, + }), + }, + { + type: "radio" as const, + label: t("home.settings.audio.transcode_mode.passthrough"), + value: AudioTranscodeMode.AllowAll, + selected: + settings?.audioTranscodeMode === AudioTranscodeMode.AllowAll, + onPress: () => + updateSettings({ + audioTranscodeMode: AudioTranscodeMode.AllowAll, + }), + }, + ], + }, + ], + [settings?.audioTranscodeMode, t, updateSettings], + ); + if (isTv) return null; if (!settings) return null; @@ -98,6 +162,31 @@ export const AudioToggles: React.FC = ({ ...props }) => { title={t("home.settings.audio.language")} /> + + + + { + audioTranscodeModeLabels[ + settings?.audioTranscodeMode || AudioTranscodeMode.Auto + ] + } + + + + } + title={t("home.settings.audio.transcode_mode.title")} + /> + ); diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index 57fdd238..1610e21d 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -5,8 +5,9 @@ import type { import { useAtomValue } from "jotai"; import type React from "react"; import { createContext, useCallback, useContext, useState } from "react"; +import { Platform } from "react-native"; import type { Bitrate } from "@/components/BitrateSelector"; -import { settingsAtom } from "@/utils/atoms/settings"; +import { settingsAtom, VideoPlayerIOS } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { generateDeviceProfile } from "@/utils/profiles/native"; import { apiAtom, userAtom } from "./JellyfinProvider"; @@ -77,7 +78,19 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ } try { - const native = generateDeviceProfile(); + // Determine which player is being used: + // - Android always uses VLC + // - iOS uses user setting (VLC is default) + const useVlcPlayer = + Platform.OS === "android" || + (Platform.OS === "ios" && + settings.videoPlayerIOS === VideoPlayerIOS.VLC); + + const native = generateDeviceProfile({ + platform: Platform.OS as "ios" | "android", + player: useVlcPlayer ? "vlc" : "ksplayer", + audioMode: settings.audioTranscodeMode, + }); const data = await getStreamUrl({ api, deviceProfile: native, diff --git a/translations/en.json b/translations/en.json index aefb1afe..1b5aae31 100644 --- a/translations/en.json +++ b/translations/en.json @@ -132,7 +132,15 @@ "audio_language": "Audio Language", "audio_hint": "Choose a default audio language.", "none": "None", - "language": "Language" + "language": "Language", + "transcode_mode": { + "title": "Audio Transcoding", + "description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled", + "auto": "Auto", + "stereo": "Force Stereo", + "5_1": "Allow 5.1", + "passthrough": "Passthrough" + } }, "subtitles": { "subtitle_title": "Subtitles", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index f56f7226..3c6fb1af 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -140,6 +140,14 @@ export enum VideoPlayerIOS { VLC = "vlc", } +// Audio transcoding mode - controls how surround audio is handled +export enum AudioTranscodeMode { + Auto = "auto", // Platform/player 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) +} + export type Settings = { home?: Home | null; deviceProfile?: "Expo" | "Native" | "Old"; @@ -218,6 +226,8 @@ export type Settings = { audioMaxCacheSizeMB: number; // Music playback preferLocalAudio: boolean; + // Audio transcoding mode + audioTranscodeMode: AudioTranscodeMode; }; export interface Lockable { @@ -316,6 +326,8 @@ export const defaultValues: Settings = { audioMaxCacheSizeMB: 500, // Music playback preferLocalAudio: true, + // Audio transcoding mode + audioTranscodeMode: AudioTranscodeMode.Auto, }; const loadSettings = (): Partial => { diff --git a/utils/profiles/native.d.ts b/utils/profiles/native.d.ts index 17ab955e..eb38b5bb 100644 --- a/utils/profiles/native.d.ts +++ b/utils/profiles/native.d.ts @@ -4,4 +4,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -export function generateDeviceProfile(): any; +export type PlatformType = "ios" | "android"; +export type PlayerType = "vlc" | "ksplayer"; +export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough"; + +export interface ProfileOptions { + /** Target platform */ + platform?: PlatformType; + /** Video player being used */ + player?: PlayerType; + /** Audio transcoding mode */ + audioMode?: AudioTranscodeModeType; +} + +export function generateDeviceProfile(options?: ProfileOptions): any; diff --git a/utils/profiles/native.js b/utils/profiles/native.js index c97115cb..761aecbb 100644 --- a/utils/profiles/native.js +++ b/utils/profiles/native.js @@ -7,12 +7,23 @@ import { Platform } from "react-native"; import MediaTypes from "../../constants/MediaTypes"; import { getSubtitleProfiles } from "./subtitles"; +/** + * @typedef {"ios" | "android"} PlatformType + * @typedef {"vlc" | "ksplayer"} PlayerType + * @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType + * + * @typedef {Object} ProfileOptions + * @property {PlatformType} [platform] - Target platform + * @property {PlayerType} [player] - Video player being used + * @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. */ -const getAudioDirectPlayProfile = () => { - if (Platform.OS === "ios") { +const getAudioDirectPlayProfile = (platform) => { + if (platform === "ios") { // iOS AVPlayer supported formats return { Type: MediaTypes.Audio, @@ -29,8 +40,8 @@ const getAudioDirectPlayProfile = () => { }; }; -const getAudioCodecProfile = () => { - if (Platform.OS === "ios") { +const getAudioCodecProfile = (platform) => { + if (platform === "ios") { // iOS AVPlayer codec constraints return { Type: MediaTypes.Audio, @@ -45,12 +56,99 @@ const getAudioCodecProfile = () => { }; }; -export const generateDeviceProfile = () => { +/** + * Gets the video audio codec configuration based on platform, player, 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. + * + * @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 baseCodecs = "aac,mp3,flac,opus,vorbis"; + + // Surround codecs that VLC handles well (downmixes properly) + const surroundCodecs = "ac3,eac3,dts"; + + // Lossless HD codecs that cause issues with VLC's downmixing on mobile + const losslessHdCodecs = "truehd"; + + // Platform-specific codecs + const platformCodecs = platform === "ios" ? "alac,wma" : "wma"; + + // Handle explicit user settings first + switch (audioMode) { + case "stereo": + // Force stereo transcoding - only allow basic codecs + return { + directPlayCodec: `${baseCodecs},${platformCodecs}`, + maxAudioChannels: "2", + }; + + case "5.1": + // Allow up to 5.1 - include surround codecs but not lossless HD + return { + directPlayCodec: `${baseCodecs},${surroundCodecs},${platformCodecs}`, + maxAudioChannels: "6", + }; + + case "passthrough": + // Allow all codecs - for users with external DAC/receiver + return { + directPlayCodec: `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`, + maxAudioChannels: "8", + }; + default: + // Auto mode: platform and player-specific defaults + break; + } + + // 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", + }; +}; + +/** + * Generates a device profile for Jellyfin playback. + * + * @param {ProfileOptions} [options] - Profile configuration options + * @returns {Object} Jellyfin device profile + */ +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 */ const profile = { - Name: `1. MPV Player`, + Name: `1. ${playerName}`, MaxStaticBitrate: 999_999_999, MaxStreamingBitrate: 999_999_999, CodecProfiles: [ @@ -76,7 +174,7 @@ export const generateDeviceProfile = () => { }, ], }, - getAudioCodecProfile(), + getAudioCodecProfile(platform), ], DirectPlayProfiles: [ { @@ -84,9 +182,9 @@ export const generateDeviceProfile = () => { 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,dts,truehd", + AudioCodec: directPlayCodec, }, - getAudioDirectPlayProfile(), + getAudioDirectPlayProfile(platform), ], TranscodingProfiles: [ { @@ -96,6 +194,7 @@ export const generateDeviceProfile = () => { Container: "ts", VideoCodec: "h264, hevc", AudioCodec: "aac,mp3,ac3,dts", + MaxAudioChannels: maxAudioChannels, }, { Type: MediaTypes.Audio,