From faa250bfdd6fea30d9a25e36e02c3729abd880af Mon Sep 17 00:00:00 2001 From: Lance Chant <13349722+lancechant@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:07:35 +0200 Subject: [PATCH] feat: adding exoplayer for HDR playback Currently MPV doesn't support HDR via external displays. giving people the choice of HDR/limited ass sub support/SDR full sub support Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 136 ++- app/(auth)/player/direct-player.tsx | 14 +- components/tv/TVNextEpisodeCountdown.tsx | 6 +- components/tv/TVSkipSegmentCard.tsx | 12 +- components/video-player/VideoPlayerView.tsx | 35 + .../video-player/controls/Controls.tv.tsx | 17 +- .../controls/TechnicalInfoOverlay.tsx | 41 +- modules/exoplayer-player/android/build.gradle | 68 ++ .../exoplayerplayer/ExoPlayerModule.kt | 193 ++++ .../modules/exoplayerplayer/ExoPlayerView.kt | 905 ++++++++++++++++++ .../exoplayer-player/expo-module.config.json | 6 + modules/exoplayer-player/index.ts | 19 + .../exoplayer-player/src/ExoPlayerView.tsx | 132 +++ modules/index.ts | 2 + modules/mpv-player/src/MpvPlayer.types.ts | 24 + providers/PlaySettingsProvider.tsx | 7 +- translations/en.json | 7 + utils/atoms/settings.ts | 33 +- utils/profiles/native.ts | 160 +++- 19 files changed, 1735 insertions(+), 82 deletions(-) create mode 100644 components/video-player/VideoPlayerView.tsx create mode 100644 modules/exoplayer-player/android/build.gradle create mode 100644 modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerModule.kt create mode 100644 modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerView.kt create mode 100644 modules/exoplayer-player/expo-module.config.json create mode 100644 modules/exoplayer-player/index.ts create mode 100644 modules/exoplayer-player/src/ExoPlayerView.tsx diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index a9a2e2fb..fd858443 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -5,7 +5,7 @@ import { Image } from "expo-image"; import { useAtom } from "jotai"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, ScrollView, View } from "react-native"; +import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal"; @@ -33,13 +33,16 @@ import { } from "@/providers/JellyfinProvider"; import { AudioTranscodeMode, + getActiveVideoPlayer, InactivityTimeout, type MpvCacheMode, type MpvVoDriver, TVTypographyScale, useSettings, + VideoPlayer, } from "@/utils/atoms/settings"; import { storage } from "@/utils/mmkv"; +import { scaleSize } from "@/utils/scaleSize"; import { getPreviousServers, type SavedServer, @@ -262,6 +265,25 @@ export default function SettingsTV() { const currentVoDriver = settings.mpvVoDriver ?? "gpu-next"; const currentLanguage = settings.preferedLanguage; + // Video player selection. MPV is the default; ExoPlayer is only offered + // as an opt-in alternative on Android TV. The selector is hidden on + // other platforms. + const isAndroidTv = Platform.OS === "android" && Platform.isTV; + const currentVideoPlayer = getActiveVideoPlayer(settings); + const isMpv = currentVideoPlayer !== VideoPlayer.ExoPlayer; + + // Shared style for the ExoPlayer / MPV limitation notes shown under the + // selector when the respective player is active. All pixel values scaled + // so the layout holds on 4K TVs (see utils/scaleSize.ts). + const playerNoteStyle = { + color: "#9CA3AF", + fontSize: typography.callout - 2, + marginTop: scaleSize(4), + marginBottom: scaleSize(12), + marginLeft: scaleSize(8), + marginRight: scaleSize(8), + } as const; + // Audio transcoding options const audioTranscodeModeOptions: TVOptionItem[] = useMemo( () => [ @@ -391,6 +413,23 @@ export default function SettingsTV() { [t, currentVoDriver], ); + // Video player backend options (Android TV only) + const videoPlayerOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.video_player.exoplayer"), + value: VideoPlayer.ExoPlayer, + selected: currentVideoPlayer === VideoPlayer.ExoPlayer, + }, + { + label: t("home.settings.video_player.mpv"), + value: VideoPlayer.MPV, + selected: currentVideoPlayer === VideoPlayer.MPV, + }, + ], + [t, currentVideoPlayer], + ); + // Typography scale options const typographyScaleOptions: TVOptionItem[] = useMemo( () => [ @@ -522,6 +561,11 @@ export default function SettingsTV() { return option?.label || t("home.settings.vo_driver.gpu_next"); }, [voDriverOptions, t]); + const videoPlayerLabel = useMemo(() => { + const option = videoPlayerOptions.find((o) => o.selected); + return option?.label || "MPV"; + }, [videoPlayerOptions]); + const languageLabel = useMemo(() => { if (!currentLanguage) return t("home.settings.languages.system"); const option = APP_LANGUAGES.find((l) => l.value === currentLanguage); @@ -586,6 +630,34 @@ export default function SettingsTV() { {/* Audio Section */} + + {/* Video Player selector — Android TV only */} + {isAndroidTv && ( + <> + + showOptions({ + title: t("home.settings.video_player.title"), + options: videoPlayerOptions, + onSelect: (value) => updateSettings({ videoPlayer: value }), + }) + } + /> + {!isMpv && ( + + {t("home.settings.video_player.exoplayer_note")} + + )} + {isMpv && ( + + {t("home.settings.video_player.mpv_note")} + + )} + + )} + - - showOptions({ - title: "Horizontal Alignment", - options: alignXOptions, - onSelect: (value) => - updateSettings({ - mpvSubtitleAlignX: value as "left" | "center" | "right", - }), - }) - } - /> + {isMpv && ( + + showOptions({ + title: "Horizontal Alignment", + options: alignXOptions, + onSelect: (value) => + updateSettings({ + mpvSubtitleAlignX: value as "left" | "center" | "right", + }), + }) + } + /> + )} - {/* Video Output Section */} - - - showOptions({ - title: t("home.settings.vo_driver.vo_mode"), - options: voDriverOptions, - onSelect: (value) => updateSettings({ mpvVoDriver: value }), - }) - } - /> + {/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */} + {isMpv && ( + <> + + + showOptions({ + title: t("home.settings.vo_driver.vo_mode"), + options: voDriverOptions, + onSelect: (value) => updateSettings({ mpvVoDriver: value }), + }) + } + /> + + )} + - = ({ diff --git a/components/tv/TVSkipSegmentCard.tsx b/components/tv/TVSkipSegmentCard.tsx index 140fa317..47d8322c 100644 --- a/components/tv/TVSkipSegmentCard.tsx +++ b/components/tv/TVSkipSegmentCard.tsx @@ -33,9 +33,15 @@ export interface TVSkipSegmentCardProps { playButtonRef?: View | null; } -// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive) -const BOTTOM_WITH_CONTROLS = 300; -const BOTTOM_WITHOUT_CONTROLS = 120; +// Position constants — kept in sync with TVNextEpisodeCountdown (the two +// are mutually exclusive). Scaled to the screen so 4K TVs don't get a +// card that floats far above the controls. +// +// BOTTOM_WITH_CONTROLS is tuned to sit just above the bottom controls bar +// (metadata + seekbar + buttons ≈ 200px on 1080p). Previously 300, which +// left the card hovering ~100px above the controls. +const BOTTOM_WITH_CONTROLS = scaleSize(220); +const BOTTOM_WITHOUT_CONTROLS = scaleSize(120); export const TVSkipSegmentCard: FC = ({ show, diff --git a/components/video-player/VideoPlayerView.tsx b/components/video-player/VideoPlayerView.tsx new file mode 100644 index 00000000..955d60ec --- /dev/null +++ b/components/video-player/VideoPlayerView.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { Platform } from "react-native"; +import type { MpvPlayerViewProps, MpvPlayerViewRef } from "@/modules"; +import { MpvPlayerView } from "@/modules"; +import { ExoPlayerView } from "@/modules/exoplayer-player"; +import { + getActiveVideoPlayer, + useSettings, + VideoPlayer, +} from "@/utils/atoms/settings"; + +/** + * Unified video player view. MPV is the default on every platform; users + * can opt into ExoPlayer on Android TV via settings.videoPlayer. Both + * children conform to the same `MpvPlayerViewRef` interface, so the ref + * is forwarded transparently regardless of which player is rendered. + */ +export const VideoPlayerView = React.forwardRef< + MpvPlayerViewRef, + MpvPlayerViewProps +>(function VideoPlayerView(props, ref) { + const { settings } = useSettings(); + + // ExoPlayer's native module only ships for Android TV. Even if a user + // somehow ends up with `videoPlayer: ExoPlayer` set on another platform + // (shouldn't happen — the selector is hidden outside Android TV — but + // MMKV-persisted settings can roam), fall back to MPV rather than + // crash on requireNativeView(). + const isExoSupported = Platform.OS === "android" && Platform.isTV; + const useExo = + isExoSupported && getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer; + + const Player = useExo ? ExoPlayerView : MpvPlayerView; + return ; +}); diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index a85f6215..0e036cf9 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -1129,7 +1129,16 @@ export const Controls: FC = ({ {/* Skip intro card */} { + // After the seek lands, showSkipButton flips false and this card + // unmounts. With controls visible the focus-stealing overlay is + // disabled, so without an explicit handoff the focus engine is + // stranded. Prime the play button to receive focus on the next + // render — when controls are hidden the focus overlay takes over + // naturally and this is a harmless no-op. + if (showControls) setFocusPlayButton(true); + skipIntro(); + }} type='intro' controlsVisible={showControls} refSetter={setSkipSegmentRef} @@ -1144,7 +1153,11 @@ export const Controls: FC = ({ (hasContentAfterCredits || !nextItem) && !isCountdownActive } - onPress={skipCredit} + onPress={() => { + // See the intro card above for the focus-handoff rationale. + if (showControls) setFocusPlayButton(true); + skipCredit(); + }} type='credits' controlsVisible={showControls} refSetter={setSkipSegmentRef} diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx index f5a2e761..ecd20865 100644 --- a/components/video-player/controls/TechnicalInfoOverlay.tsx +++ b/components/video-player/controls/TechnicalInfoOverlay.tsx @@ -213,13 +213,10 @@ export const TechnicalInfoOverlay: FC = memo( ); return { - container: mediaSource.Container, videoRange: videoStream?.VideoRangeType, bitDepth: videoStream?.BitDepth, audioChannels: audioStream?.Channels, - audioCodecFromSource: audioStream?.Codec, subtitleCodec: subtitleStream?.Codec, - subtitleTitle: subtitleStream?.DisplayTitle, }; }, [mediaSource, currentAudioIndex, currentSubtitleIndex]); @@ -305,9 +302,13 @@ export const TechnicalInfoOverlay: FC = memo( {info.videoWidth}x{info.videoHeight} {streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""} - {formatVideoRange(streamInfo?.videoRange) - ? ` ${formatVideoRange(streamInfo?.videoRange)}` - : ""} + {/* Prefer the player-reported HDR format (authoritative — + what's actually being decoded) over Jellyfin metadata. */} + {info?.hdrFormat + ? ` ${info.hdrFormat}` + : formatVideoRange(streamInfo?.videoRange) + ? ` ${formatVideoRange(streamInfo?.videoRange)}` + : ""} )} {info?.videoCodec && ( @@ -319,8 +320,15 @@ export const TechnicalInfoOverlay: FC = memo( {info?.audioCodec && ( Audio: {formatCodec(info.audioCodec)} - {streamInfo?.audioChannels - ? ` ${formatAudioChannels(streamInfo.audioChannels)}` + {/* Prefer player-reported channel count; fall back to + Jellyfin metadata for MPV which doesn't populate it. */} + {(info.audioChannels ?? streamInfo?.audioChannels) + ? ` ${formatAudioChannels( + info.audioChannels ?? streamInfo!.audioChannels!, + )}` + : ""} + {info.audioSampleRate + ? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz` : ""} )} @@ -339,6 +347,17 @@ export const TechnicalInfoOverlay: FC = memo( : "N/A"} )} + {(info?.colorSpace || info?.colorRange || info?.colorTransfer) && ( + + Color: + {[info.colorSpace, info.colorRange, info.colorTransfer] + .filter(Boolean) + .join(" / ")} + + )} + {info?.videoCodecs && ( + Codec tag: {info.videoCodecs} + )} {info?.cacheSeconds !== undefined && ( Buffer: {info.cacheSeconds.toFixed(1)}s @@ -356,6 +375,12 @@ export const TechnicalInfoOverlay: FC = memo( {info.hwdec ? ` / ${info.hwdec}` : ""} )} + {info?.decoderName && ( + + Decoder: {info.decoderName} + {info.decoderType ? ` (${info.decoderType})` : ""} + + )} {info?.estimatedVfFps !== undefined && ( Output FPS: {info.estimatedVfFps.toFixed(2)} diff --git a/modules/exoplayer-player/android/build.gradle b/modules/exoplayer-player/android/build.gradle new file mode 100644 index 00000000..52dd4609 --- /dev/null +++ b/modules/exoplayer-player/android/build.gradle @@ -0,0 +1,68 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.exoplayerplayer' +version = '0.1.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 36) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 26) + targetSdkVersion safeExtGet("targetSdkVersion", 36) + } + } +} + +android { + namespace "expo.modules.exoplayerplayer" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} + +dependencies { + // Media3 (ExoPlayer). The default tracks react-native-track-player's + // pinned version (currently 1.10.1) so we don't end up with two media3 + // versions on the classpath and duplicate-class errors. The + // DASH/SmoothStreaming/RTSP artifacts that RNTP pulls in are excluded + // globally via plugins/withExcludeMedia3Dash.js. + def media3Version = safeExtGet('media3Version', '1.10.1') + implementation "androidx.media3:media3-exoplayer:${media3Version}" + implementation "androidx.media3:media3-exoplayer-hls:${media3Version}" + implementation "androidx.media3:media3-ui:${media3Version}" + implementation "androidx.media3:media3-common:${media3Version}" + + // FFmpeg software decoders — DTS / TrueHD / AC-4 / WMA / older video + // codecs that MediaCodec doesn't ship with on most Android TVs. + // + // This is the Jellyfin-published distribution of media3-decoder-ffmpeg + // with prebuilt native libraries (the upstream androidx artifact is a + // stub that requires building FFmpeg yourself). RNTP already pulls + // 1.9.0+1 in transitively, so declaring it here is mostly defensive — + // it guarantees we still get it if RNTP ever drops the dep. + // + // Version skew: this is built against media3 1.9.0 but RNTP (and we) + // resolve media3 core to 1.10.1. RNTP ships the same combination in + // production, and Media3 maintains binary compat for Renderer / + // RenderersFactory APIs across minor versions, so this works in + // practice. Re-evaluate when Jellyfin publishes a 1.10.x build. + implementation "org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1" +} diff --git a/modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerModule.kt b/modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerModule.kt new file mode 100644 index 00000000..3f0b5bed --- /dev/null +++ b/modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerModule.kt @@ -0,0 +1,193 @@ +package expo.modules.exoplayerplayer + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExoPlayerModule : Module() { + override fun definition() = ModuleDefinition { + Name("ExoPlayer") + + // Enables the module to be used as a native view. + View(ExoPlayerView::class) { + // All video load options are passed via a single "source" prop, + // mirroring MpvPlayerView. MPV-only fields (voDriver, extra + // cacheConfig fields) are silently ignored. + Prop("source") { view: ExoPlayerView, source: Map? -> + if (source == null) return@Prop + + val urlString = source["url"] as? String ?: return@Prop + + @Suppress("UNCHECKED_CAST") + val cacheConfig = source["cacheConfig"] as? Map + + val config = VideoLoadConfig( + url = urlString, + headers = source["headers"] as? Map, + externalSubtitles = source["externalSubtitles"] as? List, + startPosition = (source["startPosition"] as? Number)?.toDouble(), + autoplay = (source["autoplay"] as? Boolean) ?: true, + initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(), + initialAudioId = (source["initialAudioId"] as? Number)?.toInt(), + cacheEnabled = cacheConfig?.get("enabled") as? String, + cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(), + demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(), + demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt() + ) + + view.loadVideo(config) + } + + // Now Playing metadata is iOS-only on MPV; no-op here (TV has + // no Control Center equivalent — Android handles media sessions + // via MediaSessionCompat which we don't wire up for TV). + Prop("nowPlayingMetadata") { _: ExoPlayerView, _: Map? -> + // No-op + } + + AsyncFunction("play") { view: ExoPlayerView -> + view.play() + } + + AsyncFunction("pause") { view: ExoPlayerView -> + view.pause() + } + + AsyncFunction("destroy") { view: ExoPlayerView -> + view.destroy() + } + + AsyncFunction("seekTo") { view: ExoPlayerView, position: Double -> + view.seekTo(position) + } + + AsyncFunction("seekBy") { view: ExoPlayerView, offset: Double -> + view.seekBy(offset) + } + + AsyncFunction("setSpeed") { view: ExoPlayerView, speed: Double -> + view.setSpeed(speed) + } + + AsyncFunction("getSpeed") { view: ExoPlayerView -> + view.getSpeed() + } + + AsyncFunction("isPaused") { view: ExoPlayerView -> + view.isPaused() + } + + AsyncFunction("getCurrentPosition") { view: ExoPlayerView -> + view.getCurrentPosition() + } + + AsyncFunction("getDuration") { view: ExoPlayerView -> + view.getDuration() + } + + // Picture in Picture — TV does not use PiP; safe no-ops. + AsyncFunction("startPictureInPicture") { _: ExoPlayerView -> + // No-op + } + + AsyncFunction("stopPictureInPicture") { _: ExoPlayerView -> + // No-op + } + + AsyncFunction("isPictureInPictureSupported") { _: ExoPlayerView -> + false + } + + AsyncFunction("isPictureInPictureActive") { _: ExoPlayerView -> + false + } + + // Subtitle functions + AsyncFunction("getSubtitleTracks") { view: ExoPlayerView -> + view.getSubtitleTracks() + } + + AsyncFunction("setSubtitleTrack") { view: ExoPlayerView, trackId: Int -> + view.setSubtitleTrack(trackId) + } + + AsyncFunction("disableSubtitles") { view: ExoPlayerView -> + view.disableSubtitles() + } + + AsyncFunction("getCurrentSubtitleTrack") { view: ExoPlayerView -> + view.getCurrentSubtitleTrack() + } + + AsyncFunction("addSubtitleFile") { view: ExoPlayerView, url: String, select: Boolean -> + view.addSubtitleFile(url, select) + } + + // Subtitle positioning / styling + AsyncFunction("setSubtitlePosition") { view: ExoPlayerView, position: Int -> + view.setSubtitlePosition(position) + } + + AsyncFunction("setSubtitleScale") { view: ExoPlayerView, scale: Double -> + view.setSubtitleScale(scale) + } + + AsyncFunction("setSubtitleMarginY") { view: ExoPlayerView, margin: Int -> + view.setSubtitleMarginY(margin) + } + + AsyncFunction("setSubtitleAlignX") { _: ExoPlayerView, _: String -> + // No-op — SubtitleView follows authored cue alignment. + } + + AsyncFunction("setSubtitleAlignY") { view: ExoPlayerView, alignment: String -> + view.setSubtitleAlignY(alignment) + } + + AsyncFunction("setSubtitleFontSize") { view: ExoPlayerView, size: Int -> + view.setSubtitleFontSize(size) + } + + AsyncFunction("setSubtitleBorderStyle") { view: ExoPlayerView, style: String -> + view.setSubtitleBorderStyle(style) + } + + AsyncFunction("setSubtitleBackgroundColor") { view: ExoPlayerView, color: String -> + view.setSubtitleBackgroundColor(color) + } + + AsyncFunction("setSubtitleAssOverride") { _: ExoPlayerView, _: String -> + // No-op — libass-specific, no Media3 equivalent. + } + + // Audio track functions + AsyncFunction("getAudioTracks") { view: ExoPlayerView -> + view.getAudioTracks() + } + + AsyncFunction("setAudioTrack") { view: ExoPlayerView, trackId: Int -> + view.setAudioTrack(trackId) + } + + AsyncFunction("getCurrentAudioTrack") { view: ExoPlayerView -> + view.getCurrentAudioTrack() + } + + // Video scaling + AsyncFunction("setZoomedToFill") { view: ExoPlayerView, zoomed: Boolean -> + view.setZoomedToFill(zoomed) + } + + AsyncFunction("isZoomedToFill") { view: ExoPlayerView -> + view.isZoomedToFill() + } + + // Technical info + AsyncFunction("getTechnicalInfo") { view: ExoPlayerView -> + view.getTechnicalInfo() + } + + // Events that the view can send to JavaScript — same set as MPV. + Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange") + } + } +} diff --git a/modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerView.kt b/modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerView.kt new file mode 100644 index 00000000..788a9735 --- /dev/null +++ b/modules/exoplayer-player/android/src/main/java/expo/modules/exoplayerplayer/ExoPlayerView.kt @@ -0,0 +1,905 @@ +@file:OptIn(androidx.media3.common.util.UnstableApi::class) + +package expo.modules.exoplayerplayer + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.ViewGroup +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.ColorInfo +import androidx.media3.common.Format +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.PlayerView +import androidx.media3.ui.SubtitleView +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +/** + * Configuration for loading a video. Mirrors MpvPlayerView.VideoLoadConfig — + * MPV-only fields are accepted and ignored. + */ +data class VideoLoadConfig( + val url: String, + val headers: Map? = null, + val externalSubtitles: List? = null, + val startPosition: Double? = null, + val autoplay: Boolean = true, + val initialSubtitleId: Int? = null, + val initialAudioId: Int? = null, + val cacheEnabled: String? = null, + val cacheSeconds: Int? = null, + val demuxerMaxBytes: Int? = null, + val demuxerMaxBackBytes: Int? = null, +) + +/** + * ExoPlayerView — ExpoView that hosts a Media3 ExoPlayer instance. + * + * Implements the same JS contract (events, ref methods, 1-based track IDs) + * as MpvPlayerView so the React layer can swap between the two without + * changes. Subtitle styling is mapped to androidx.media3.ui.SubtitleView + + * CaptionStyleCompat. PiP methods are no-ops (TV doesn't use PiP). + */ +class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + + companion object { + private const val TAG = "ExoPlayerView" + private const val PROGRESS_INTERVAL_MS = 1000L + } + + // Event dispatchers — names must match the Events() declaration in the module. + val onLoad by EventDispatcher() + val onPlaybackStateChange by EventDispatcher() + val onProgress by EventDispatcher() + val onError by EventDispatcher() + val onTracksReady by EventDispatcher() + val onPictureInPictureChange by EventDispatcher() + + private val mainHandler = Handler(Looper.getMainLooper()) + + private var player: ExoPlayer? = null + private val playerView: PlayerView + private val subtitleView: SubtitleView? + + private var currentUrl: String? = null + private var pendingConfig: VideoLoadConfig? = null + private var tracksReadyFired: Boolean = false + + // 1-based track ID mappings (matching MPV's contract). + // Each list is rebuilt on Tracks changed. + private var subtitleTrackList: List = emptyList() + private var audioTrackList: List = emptyList() + private var currentSubtitleId: Int = 0 + private var currentAudioId: Int = 0 + + // Subtitle styling state — applied to the embedded SubtitleView. + private var subtitleScale: Float = 1f + private var subtitleFontSizePct: Int? = null // 0-100 + // Last-write-wins override of the vertical position fraction + // (null = fall back to subtitleAlignY). Both setSubtitlePosition + // (0-100, MPV convention where 100 = bottom) and setSubtitleMarginY + // (px) funnel into this single SubtitleView API. + private var subtitleBottomFraction: Float? = null + private var subtitleAlignY: String = "bottom" + // Background color carries its own alpha (parsed from #RRGGBBAA in + // setSubtitleBackgroundColor) so no separate enabled/opacity flags. + private var subtitleBackgroundColor: Int = Color.argb(0, 0, 0, 0) + private var subtitleBorderStyle: String = "outline-and-shadow" + + private var isZoomedToFill: Boolean = false + + // Captured by analyticsListener; surfaced via getTechnicalInfo(). + // Reset on destroy() and (for decoder names) on track changes. + private var videoDecoderName: String? = null + private var audioDecoderName: String? = null + private var cumulativeDroppedFrames: Int = 0 + + private val analyticsListener = object : AnalyticsListener { + override fun onVideoDecoderInitialized( + eventTime: AnalyticsListener.EventTime, + decoderName: String, + initializedTimestampMs: Long, + ) { + videoDecoderName = decoderName + } + + override fun onAudioDecoderInitialized( + eventTime: AnalyticsListener.EventTime, + decoderName: String, + initializedTimestampMs: Long, + ) { + audioDecoderName = decoderName + } + + override fun onDroppedVideoFrames( + eventTime: AnalyticsListener.EventTime, + droppedFrames: Int, + elapsedMs: Long, + ) { + // Incremental count since last call; accumulate for a cumulative + // total that matches MPV's droppedFrames semantics. + cumulativeDroppedFrames += droppedFrames + } + } + + private val playerListener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING -> { + onPlaybackStateChange(mapOf("isLoading" to true)) + } + Player.STATE_READY -> { + onPlaybackStateChange(mapOf( + "isLoading" to false, + "isReadyToSeek" to true + )) + if (!tracksReadyFired) { + tracksReadyFired = true + rebuildTrackMaps(player?.currentTracks) + onTracksReady(emptyMap()) + } + } + Player.STATE_ENDED -> { + onPlaybackStateChange(mapOf( + "isPlaying" to false, + "isPaused" to true + )) + } + Player.STATE_IDLE -> { + // no-op + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + onPlaybackStateChange(mapOf( + "isPlaying" to isPlaying, + "isPaused" to !isPlaying + )) + } + + override fun onPlayerErrorChanged(error: androidx.media3.common.PlaybackException?) { + val message = error?.message ?: "Unknown playback error" + Log.e(TAG, "Player error: $message", error) + onError(mapOf("error" to message)) + } + + override fun onTracksChanged(tracks: Tracks) { + rebuildTrackMaps(tracks) + applyInitialTrackSelections() + // A track change can re-initialize the codec under a different + // name (e.g. adaptive switch from HEVC to AV1). Clear stale + // decoder names so getTechnicalInfo() doesn't report the + // previous codec until the next onVideoDecoderInitialized fires. + videoDecoderName = null + audioDecoderName = null + } + } + + private val progressRunnable = object : Runnable { + override fun run() { + val p = player ?: return + val positionMs = p.currentPosition + val durationMs = p.duration + val bufferedMs = p.bufferedPosition + + val positionSec = positionMs / 1000.0 + val durationSec = if (durationMs > 0) durationMs / 1000.0 else 0.0 + val cacheSec = if (bufferedMs > positionMs) (bufferedMs - positionMs) / 1000.0 else 0.0 + + onProgress(mapOf( + "position" to positionSec, + "duration" to durationSec, + "progress" to if (durationSec > 0) positionSec / durationSec else 0.0, + "cacheSeconds" to cacheSec + )) + + mainHandler.postDelayed(this, PROGRESS_INTERVAL_MS) + } + } + + init { + setBackgroundColor(Color.BLACK) + + playerView = PlayerView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + // SurfaceView-backed for parity with MPV (direct surface to + // SurfaceFlinger). PlayerView defaults to a SurfaceView, so no + // explicit setSurfaceType() call is needed; the int constants + // backing it are @IntDef private in Media3. + setUseController(false) + setResizeMode(androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT) + } + subtitleView = playerView.subtitleView + addView(playerView) + } + + // MARK: - Video Loading + + fun loadVideo(config: VideoLoadConfig) { + if (currentUrl == config.url) return + currentUrl = config.url + pendingConfig = config + ensurePlayer(config) + loadInternal(config) + } + + private fun ensurePlayer(config: VideoLoadConfig) { + if (player != null) return + + val loadControl = buildLoadControl(config) + + // PREFER extension renderers so the FFmpeg decoder (DTS / TrueHD / + // AC-4 / WMA / etc.) takes over when MediaCodec doesn't ship a + // hardware decoder for the format. MediaCodec remains the fallback. + val renderersFactory = androidx.media3.exoplayer.DefaultRenderersFactory(context) + .setExtensionRendererMode( + androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + ) + .setEnableDecoderFallback(true) + + val exo = ExoPlayer.Builder(context, renderersFactory) + .setLoadControl(loadControl) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(), + /* handleAudioFocus = */ true + ) + .build() + + exo.addListener(playerListener) + exo.addAnalyticsListener(analyticsListener) + exo.repeatMode = Player.REPEAT_MODE_OFF + player = exo + playerView.player = exo + + applySubtitleStyle() + } + + private fun buildLoadControl(config: VideoLoadConfig): DefaultLoadControl { + // Map MPV-style cache config to ExoPlayer's LoadControl. + val cacheEnabled = when (config.cacheEnabled) { + "no" -> false + "yes" -> true + else -> true // "auto" + } + + // Buffer thresholds used as fallbacks when the user's cache config + // doesn't override them. Media3's own defaults changed in 1.6.0 + // (bufferForPlaybackMs 2500→1000, afterRebuffer 5000→2000) for a + // faster start; we intentionally keep the older 2500/5000 here + // because low-RAM Android TVs with slow tuners benefit from the + // extra headroom before playback kicks in. Media3's DEFAULT_* + // IntDef fields are private, hence the literals. + val defaultMinBufferMs = 15000 + val defaultBufferForPlaybackMs = 2500 + val defaultBufferForPlaybackAfterRebufferMs = 5000 + + val targetBufferMs = if (!cacheEnabled) { + 50000 + } else { + val seconds = config.cacheSeconds?.coerceIn(5, 120) ?: 10 + seconds * 1000 + } + + val backBufferMs = if (!cacheEnabled) { + 0 + } else { + val mb = config.demuxerMaxBackBytes ?: 50 + // Heuristic: 1 MB ≈ 1s of typical 1080p bitrate. + (mb * 1000).coerceAtLeast(1000) + } + + val builder = DefaultLoadControl.Builder() + .setTargetBufferBytes(if (!cacheEnabled) 0 else ((config.demuxerMaxBytes ?: 150) * 1024 * 1024)) + .setBufferDurationsMs( + /* minBufferMs = */ defaultMinBufferMs, + /* maxBufferMs = */ targetBufferMs, + /* bufferForPlaybackMs = */ defaultBufferForPlaybackMs, + /* bufferForPlaybackAfterRebufferMs = */ defaultBufferForPlaybackAfterRebufferMs + ) + if (cacheEnabled) { + builder.setBackBuffer(backBufferMs, /* retainBackBufferFromKeyframe = */ true) + } + return builder.build() + } + + private fun loadInternal(config: VideoLoadConfig) { + val p = player ?: return + + val httpFactory = androidx.media3.datasource.DefaultHttpDataSource.Factory() + .setDefaultRequestProperties(config.headers ?: emptyMap()) + val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory) + + val mediaItem = buildMediaItem(config) + + val mediaSource = DefaultMediaSourceFactory(dataSourceFactory) + .createMediaSource(mediaItem) + + p.setMediaSource(mediaSource) + p.prepare() + + // Apply initial playback position + config.startPosition?.let { startPosSec -> + if (startPosSec > 0) { + p.seekTo((startPosSec * 1000).toLong()) + } + } + + if (config.autoplay) { + p.play() + } + + onLoad(mapOf("url" to config.url)) + startProgressLoop() + } + + private fun buildMediaItem(config: VideoLoadConfig): MediaItem { + val builder = MediaItem.Builder().setUri(config.url) + + // External subtitles: add as side-loaded SubtitleConfigurations. + // MIME-type sniffed from the file extension. + val subs = config.externalSubtitles + if (!subs.isNullOrEmpty()) { + val subtitleConfigs = subs.mapNotNull { subUrl -> + val mime = mimeTypeForSubtitleUrl(subUrl) ?: return@mapNotNull null + MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUrl)) + .setMimeType(mime) + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .build() + } + if (subtitleConfigs.isNotEmpty()) { + builder.setSubtitleConfigurations(subtitleConfigs) + } + } + + return builder.build() + } + + private fun mimeTypeForSubtitleUrl(url: String): String? { + val lower = url.substringBeforeLast('?').lowercase() + return when { + lower.endsWith(".vtt") || lower.endsWith(".webvtt") -> "text/vtt" + lower.endsWith(".srt") -> "application/x-subrip" + lower.endsWith(".ssa") || lower.endsWith(".ass") -> "text/x-ssa" + lower.endsWith(".ttml") || lower.endsWith(".xml") -> "application/ttml+xml" + else -> null + } + } + + // MARK: - Playback Controls + + fun play() { + player?.play() + } + + fun pause() { + player?.pause() + } + + fun destroy() { + stopProgressLoop() + player?.release() + player = null + playerView.player = null + tracksReadyFired = false + currentUrl = null + subtitleTrackList = emptyList() + audioTrackList = emptyList() + currentSubtitleId = 0 + currentAudioId = 0 + videoDecoderName = null + audioDecoderName = null + cumulativeDroppedFrames = 0 + } + + fun seekTo(positionSec: Double) { + player?.seekTo((positionSec * 1000).toLong()) + } + + fun seekBy(offsetSec: Double) { + val p = player ?: return + val target = (p.currentPosition + offsetSec * 1000).coerceAtLeast(0.0) + p.seekTo(target.toLong()) + } + + fun setSpeed(speed: Double) { + player?.playbackParameters = PlaybackParameters(speed.toFloat()) + } + + fun getSpeed(): Float { + return player?.playbackParameters?.speed ?: 1f + } + + fun isPaused(): Boolean { + return player?.isPlaying == false + } + + fun getCurrentPosition(): Double { + return (player?.currentPosition ?: 0L) / 1000.0 + } + + fun getDuration(): Double { + val d = player?.duration ?: 0L + return if (d > 0) d / 1000.0 else 0.0 + } + + // MARK: - Track Mapping (1-based IDs to match MPV's contract) + + data class TrackEntry( + val id: Int, // 1-based JS-facing ID + val trackGroupIndex: Int, + val trackIndex: Int, + val format: Format, + ) + + private fun rebuildTrackMaps(tracks: Tracks?) { + if (tracks == null) return + + val subtitles = mutableListOf() + val audios = mutableListOf() + + tracks.groups.forEachIndexed { groupIndex, group -> + val rendererType = group.type + // Skip groups that have no tracks the player supports + for (trackIdx in 0 until group.length) { + if (!group.isTrackSupported(trackIdx)) continue + val format = group.getTrackFormat(trackIdx) + val entry = TrackEntry( + id = 0, // assigned per-list below + trackGroupIndex = groupIndex, + trackIndex = trackIdx, + format = format + ) + when (rendererType) { + C.TRACK_TYPE_TEXT -> subtitles.add(entry) + C.TRACK_TYPE_AUDIO -> audios.add(entry) + else -> { /* video / metadata ignored */ } + } + } + } + + // Assign 1-based IDs per track kind. + subtitles.forEachIndexed { i, e -> subtitles[i] = e.copy(id = i + 1) } + audios.forEachIndexed { i, e -> audios[i] = e.copy(id = i + 1) } + + subtitleTrackList = subtitles + audioTrackList = audios + } + + private fun applyInitialTrackSelections() { + val p = player ?: return + val cfg = pendingConfig ?: return + + // Initial subtitle/audio selection by 1-based ID. + if (cfg.initialAudioId != null && cfg.initialAudioId > 0) { + setAudioTrack(cfg.initialAudioId) + } + if (cfg.initialSubtitleId == null || cfg.initialSubtitleId <= 0) { + disableSubtitles() + } else { + setSubtitleTrack(cfg.initialSubtitleId) + } + + // Only apply once per source load. + pendingConfig = null + } + + // MARK: - Subtitle Controls + + fun getSubtitleTracks(): List> { + return subtitleTrackList.map { entry -> + mapOf( + "id" to entry.id, + "title" to (entry.format.label ?: ""), + "lang" to (entry.format.language ?: "") + ) + } + } + + fun setSubtitleTrack(trackId: Int) { + val p = player ?: return + val entry = subtitleTrackList.firstOrNull { it.id == trackId } ?: return + val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup + + // setOverrideForType replaces any existing override of the same + // track type — exactly what we want for single-track subtitle pickers. + val params = p.trackSelectionParameters.buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex)) + .build() + p.trackSelectionParameters = params + currentSubtitleId = trackId + } + + fun disableSubtitles() { + val p = player ?: return + val params = p.trackSelectionParameters.buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + .build() + p.trackSelectionParameters = params + currentSubtitleId = 0 + } + + fun getCurrentSubtitleTrack(): Int = currentSubtitleId + + fun addSubtitleFile(url: String, select: Boolean) { + val p = player ?: return + // Media3 does not expose the current MediaItem's existing + // SubtitleConfigurations, so we cannot append a side-loaded + // subtitle to a running item without losing the originals. + // For TV, external subs are bundled at load time via + // VideoLoadConfig.externalSubtitles (see buildMediaItem). This + // method rebuilds the current MediaItem with just the new + // subtitle config — acceptable when no other external subs are + // in play, which is the typical TV case. + val mime = mimeTypeForSubtitleUrl(url) ?: return + val currentMediaItem = p.currentMediaItem ?: return + val newSubConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(url)) + .setMimeType(mime) + .setSelectionFlags(if (select) C.SELECTION_FLAG_DEFAULT else 0) + .build() + + val rebuilt = currentMediaItem.buildUpon() + .setSubtitleConfigurations(listOf(newSubConfig)) + .build() + + val wasPlaying = p.isPlaying + val pos = p.currentPosition + p.setMediaItem(rebuilt, pos) + p.prepare() + if (wasPlaying) p.play() + } + + // MARK: - Subtitle Positioning / Styling + + fun setSubtitlePosition(position: Int) { + // position is 0-100 (MPV convention: 100 = bottom, 0 = top). + // Map to SubtitleView's bottom-padding fraction. Reserve a small + // margin so 100 doesn't hug the very bottom edge. + val clamped = position.coerceIn(0, 100) + subtitleBottomFraction = 0.95f - (clamped / 100f) * 0.87f + applySubtitleStyle() + } + + fun setSubtitleScale(scale: Double) { + subtitleScale = scale.toFloat() + applySubtitleStyle() + } + + fun setSubtitleMarginY(margin: Int) { + // Margin in px (approximate). SubtitleView only accepts a single + // bottom-padding fraction, so convert via a heuristic (1px ≈ 0.1% + // of view height, capped). Last-write-wins vs. setSubtitlePosition. + val fraction = (margin / 1000f).coerceIn(0.02f, 0.95f) + subtitleBottomFraction = fraction + applySubtitleStyle() + } + + fun setSubtitleAlignY(alignment: String) { + subtitleAlignY = alignment + applySubtitleStyle() + } + + fun setSubtitleFontSize(size: Int) { + subtitleFontSizePct = size + applySubtitleStyle() + } + + fun setSubtitleBackgroundColor(colorHex: String) { + subtitleBackgroundColor = parseColor(colorHex, subtitleBackgroundColor) + applySubtitleStyle() + } + + fun setSubtitleBorderStyle(style: String) { + subtitleBorderStyle = style + applySubtitleStyle() + } + + private fun parseColor(hex: String, fallback: Int): Int { + return try { + when { + hex.startsWith("#") && hex.length == 9 -> { + // #RRGGBBAA + val r = hex.substring(1, 3).toInt(16) + val g = hex.substring(3, 5).toInt(16) + val b = hex.substring(5, 7).toInt(16) + val a = hex.substring(7, 9).toInt(16) + Color.argb(a, r, g, b) + } + hex.startsWith("#") && hex.length == 7 -> Color.parseColor(hex) + else -> fallback + } + } catch (_: Throwable) { + fallback + } + } + + private fun applySubtitleStyle() { + val sv = subtitleView ?: return + + // Text size: explicit % wins; otherwise scale the default. + val textSizeFraction = if (subtitleFontSizePct != null) { + (subtitleFontSizePct!! / 100f) * SubtitleView.DEFAULT_TEXT_SIZE_FRACTION + } else { + SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * subtitleScale + } + sv.setFractionalTextSize(textSizeFraction) + + // Vertical position: explicit fraction (from setSubtitlePosition / + // setSubtitleMarginY) wins; otherwise fall back to alignY mapping. + val alignYFraction = when (subtitleAlignY) { + "top" -> 0.9f + "center" -> 0.5f + else -> 0.08f // bottom + } + val bottomFraction = subtitleBottomFraction ?: alignYFraction + sv.setBottomPaddingFraction(bottomFraction.coerceIn(0.02f, 0.95f)) + + // Edge / background style. + val foreground = Color.WHITE + val edgeType: Int + val backgroundColor: Int + when (subtitleBorderStyle) { + "background-box" -> { + edgeType = CaptionStyleCompat.EDGE_TYPE_NONE + // subtitleBackgroundColor already carries its own alpha + // (parsed from #RRGGBBAA by setSubtitleBackgroundColor). + // Alpha 0 → transparent, matching user intent. + backgroundColor = subtitleBackgroundColor + } + else -> { + // "outline-and-shadow" + edgeType = if (subtitleAlignY == "center") + CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW + else + CaptionStyleCompat.EDGE_TYPE_OUTLINE + backgroundColor = Color.TRANSPARENT + } + } + + val style = CaptionStyleCompat( + foreground, + backgroundColor, + Color.TRANSPARENT, + edgeType, + Color.BLACK, + Typeface.SANS_SERIF + ) + sv.setApplyEmbeddedStyles(false) + sv.setApplyEmbeddedFontSizes(false) + sv.setStyle(style) + } + + // MARK: - Audio Track Controls + + fun getAudioTracks(): List> { + return audioTrackList.map { entry -> + // channelCount is Format.NO_VALUE (-1) when unknown — report 0. + val channels = if (entry.format.channelCount == Format.NO_VALUE) 0 + else entry.format.channelCount + mapOf( + "id" to entry.id, + "title" to (entry.format.label ?: ""), + "lang" to (entry.format.language ?: ""), + "codec" to (entry.format.sampleMimeType ?: ""), + "channels" to channels + ) + } + } + + fun setAudioTrack(trackId: Int) { + val p = player ?: return + val entry = audioTrackList.firstOrNull { it.id == trackId } ?: return + val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup + + val params = p.trackSelectionParameters.buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) + .setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex)) + .build() + p.trackSelectionParameters = params + currentAudioId = trackId + } + + fun getCurrentAudioTrack(): Int = currentAudioId + + // MARK: - Video Scaling + + fun setZoomedToFill(zoomed: Boolean) { + isZoomedToFill = zoomed + val resizeMode = if (zoomed) { + androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } else { + androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT + } + playerView.resizeMode = resizeMode + } + + fun isZoomedToFill(): Boolean = isZoomedToFill + + // MARK: - Technical Info + + fun getTechnicalInfo(): Map { + val p = player ?: return emptyMap() + val tracks = p.currentTracks + + // Prefer the currently-selected track within each renderer group; + // fall back to the first supported track if none is selected yet. + val videoFormat = pickFormat(tracks, C.TRACK_TYPE_VIDEO) + val audioFormat = pickFormat(tracks, C.TRACK_TYPE_AUDIO) + + val cacheSec = if (p.bufferedPosition > p.currentPosition) { + (p.bufferedPosition - p.currentPosition) / 1000.0 + } else 0.0 + + val info = LinkedHashMap() + info["cacheSeconds"] = cacheSec + + // Dropped frames — populated by analyticsListener.onDroppedVideoFrames. + if (cumulativeDroppedFrames > 0) { + info["droppedFrames"] = cumulativeDroppedFrames + } + + // Decoder info — populated by analyticsListener.onVideo/AudioDecoderInitialized. + // For ExoPlayer this replaces MPV's voDriver/hwdec pairing. The + // FFmpeg extension reports names beginning with "FFmpeg", which we + // classify as software; everything else is MediaCodec (hardware). + videoDecoderName?.let { name -> + info["decoderName"] = name + info["decoderType"] = if (name.lowercase().startsWith("ffmpeg")) { + "software" + } else { + "hardware" + } + } + + videoFormat?.let { f -> + if (f.width != Format.NO_VALUE) info["videoWidth"] = f.width + if (f.height != Format.NO_VALUE) info["videoHeight"] = f.height + f.sampleMimeType?.let { info["videoCodec"] = it } + // FPS: Format.NO_VALUE (-1f) means unknown — omit so the + // overlay skips the row instead of showing "-1". + if (f.frameRate > 0f) { + info["fps"] = f.frameRate.toDouble() + } + // Bitrate: prefer average, fall back to peak. Both can be + // NO_VALUE for adaptive HLS renditions — omit when unknown + // rather than reporting 0 Kbps. + val vBitrate = if (f.averageBitrate != Format.NO_VALUE) { + f.averageBitrate + } else { + f.peakBitrate + } + if (vBitrate != Format.NO_VALUE && vBitrate > 0) { + info["videoBitrate"] = vBitrate.toDouble() + } + + // Raw codec tag from the container (e.g. "hev1.2.4.L153.B0"). + // Carries profile / tier / level / constraint bytes — power + // users can decode it manually to see why a stream hit our + // HEVC level cap. + f.codecs?.let { info["videoCodecs"] = it } + + // HDR / color metadata. Format.colorInfo is the authoritative + // source — the file/Jellyfin may claim HDR but the player is + // what decides whether the decoder+surface path is HDR-capable. + f.colorInfo?.let { ci -> + val hdr = deriveHdrFormat(ci) + if (hdr != null) info["hdrFormat"] = hdr + colorSpaceName(ci.colorSpace)?.let { info["colorSpace"] = it } + colorRangeName(ci.colorRange)?.let { info["colorRange"] = it } + colorTransferName(ci.colorTransfer)?.let { info["colorTransfer"] = it } + } + } + + audioFormat?.let { f -> + f.sampleMimeType?.let { info["audioCodec"] = it } + val aBitrate = if (f.averageBitrate != Format.NO_VALUE) { + f.averageBitrate + } else { + f.peakBitrate + } + if (aBitrate != Format.NO_VALUE && aBitrate > 0) { + info["audioBitrate"] = aBitrate.toDouble() + } + if (f.channelCount > 0) info["audioChannels"] = f.channelCount + if (f.sampleRate > 0) info["audioSampleRate"] = f.sampleRate + } + + return info + } + + /** + * Map the active color transfer to a human-readable HDR format string. + * Returns null for SDR / unknown so the overlay can skip the row. + * + * HDR10 vs HDR10+ distinction isn't possible from Format alone in + * Media3 — HDR10+ is signaled via ST2094-40 SEI metadata which isn't + * exposed on Format. Both report as "HDR10" here; that matches what + * Media3 actually decodes (no HDR10+ tone-mapping). + */ + private fun deriveHdrFormat(ci: ColorInfo): String? { + return when (ci.colorTransfer) { + C.COLOR_TRANSFER_HLG -> "HLG" + C.COLOR_TRANSFER_ST2084 -> "HDR10" + else -> null + } + } + + private fun colorSpaceName(value: Int): String? = when (value) { + Format.NO_VALUE -> null + C.COLOR_SPACE_BT709 -> "BT.709" + C.COLOR_SPACE_BT601 -> "BT.601" + C.COLOR_SPACE_BT2020 -> "BT.2020" + else -> "Unknown" + } + + private fun colorRangeName(value: Int): String? = when (value) { + Format.NO_VALUE -> null + C.COLOR_RANGE_LIMITED -> "Limited" + C.COLOR_RANGE_FULL -> "Full" + else -> "Unknown" + } + + private fun colorTransferName(value: Int): String? = when (value) { + Format.NO_VALUE -> null + C.COLOR_TRANSFER_SDR -> "SDR" + C.COLOR_TRANSFER_ST2084 -> "ST2084 (PQ)" + C.COLOR_TRANSFER_HLG -> "HLG" + C.COLOR_TRANSFER_GAMMA_2_2 -> "Gamma 2.2" + else -> "Unknown" + } + + private fun pickFormat(tracks: Tracks, type: Int): Format? { + val group = tracks.groups.firstOrNull { it.type == type } ?: return null + // Selected track wins. + for (i in 0 until group.length) { + if (group.isTrackSelected(i)) return group.getTrackFormat(i) + } + // Otherwise the first supported track. + for (i in 0 until group.length) { + if (group.isTrackSupported(i)) return group.getTrackFormat(i) + } + return null + } + + // MARK: - Progress Loop + + private fun startProgressLoop() { + stopProgressLoop() + mainHandler.postDelayed(progressRunnable, PROGRESS_INTERVAL_MS) + } + + private fun stopProgressLoop() { + mainHandler.removeCallbacks(progressRunnable) + } + + // MARK: - Cleanup + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + destroy() + } +} diff --git a/modules/exoplayer-player/expo-module.config.json b/modules/exoplayer-player/expo-module.config.json new file mode 100644 index 00000000..5ac1e905 --- /dev/null +++ b/modules/exoplayer-player/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"] + } +} diff --git a/modules/exoplayer-player/index.ts b/modules/exoplayer-player/index.ts new file mode 100644 index 00000000..7cd1159e --- /dev/null +++ b/modules/exoplayer-player/index.ts @@ -0,0 +1,19 @@ +// Re-export the shared player contract from mpv-player so ExoPlayer +// and MPV present identical surfaces to React. The MPV-prefixed setting +// keys keep their names to avoid migrating existing installs. +export type { + AudioTrack, + MpvPlayerViewProps, + MpvPlayerViewRef, + NowPlayingMetadata, + OnErrorEventPayload, + OnLoadEventPayload, + OnPictureInPictureChangePayload, + OnPlaybackStateChangePayload, + OnProgressEventPayload, + OnTracksReadyEventPayload, + SubtitleTrack, + TechnicalInfo, + VideoSource, +} from "../mpv-player/src/MpvPlayer.types"; +export { default as ExoPlayerView } from "./src/ExoPlayerView"; diff --git a/modules/exoplayer-player/src/ExoPlayerView.tsx b/modules/exoplayer-player/src/ExoPlayerView.tsx new file mode 100644 index 00000000..c129f7ad --- /dev/null +++ b/modules/exoplayer-player/src/ExoPlayerView.tsx @@ -0,0 +1,132 @@ +import { requireNativeView } from "expo"; +import * as React from "react"; +import { useImperativeHandle, useRef } from "react"; + +import type { + MpvPlayerViewProps, + MpvPlayerViewRef, +} from "../mpv-player/src/MpvPlayer.types"; + +const NativeView: React.ComponentType = + requireNativeView("ExoPlayer"); + +/** + * ExoPlayer view wrapper. Exposes the same `MpvPlayerViewRef` interface as + * `MpvPlayerView` so callers can swap between the two players without + * changing code. PiP / ASS-override methods are forwarded to the native + * module which implements them as no-ops. + */ +export default React.forwardRef( + function ExoPlayerView(props, ref) { + const nativeRef = useRef(null); + + useImperativeHandle(ref, () => ({ + play: async () => { + await nativeRef.current?.play(); + }, + pause: async () => { + await nativeRef.current?.pause(); + }, + destroy: async () => { + await nativeRef.current?.destroy(); + }, + seekTo: async (position: number) => { + await nativeRef.current?.seekTo(position); + }, + seekBy: async (offset: number) => { + await nativeRef.current?.seekBy(offset); + }, + setSpeed: async (speed: number) => { + await nativeRef.current?.setSpeed(speed); + }, + getSpeed: async () => { + return await nativeRef.current?.getSpeed(); + }, + isPaused: async () => { + return await nativeRef.current?.isPaused(); + }, + getCurrentPosition: async () => { + return await nativeRef.current?.getCurrentPosition(); + }, + getDuration: async () => { + return await nativeRef.current?.getDuration(); + }, + startPictureInPicture: async () => { + await nativeRef.current?.startPictureInPicture(); + }, + stopPictureInPicture: async () => { + await nativeRef.current?.stopPictureInPicture(); + }, + isPictureInPictureSupported: async () => { + return await nativeRef.current?.isPictureInPictureSupported(); + }, + isPictureInPictureActive: async () => { + return await nativeRef.current?.isPictureInPictureActive(); + }, + getSubtitleTracks: async () => { + return await nativeRef.current?.getSubtitleTracks(); + }, + setSubtitleTrack: async (trackId: number) => { + await nativeRef.current?.setSubtitleTrack(trackId); + }, + disableSubtitles: async () => { + await nativeRef.current?.disableSubtitles(); + }, + getCurrentSubtitleTrack: async () => { + return await nativeRef.current?.getCurrentSubtitleTrack(); + }, + addSubtitleFile: async (url: string, select = true) => { + await nativeRef.current?.addSubtitleFile(url, select); + }, + setSubtitlePosition: async (position: number) => { + await nativeRef.current?.setSubtitlePosition(position); + }, + setSubtitleScale: async (scale: number) => { + await nativeRef.current?.setSubtitleScale(scale); + }, + setSubtitleMarginY: async (margin: number) => { + await nativeRef.current?.setSubtitleMarginY(margin); + }, + setSubtitleAlignX: async (alignment: "left" | "center" | "right") => { + await nativeRef.current?.setSubtitleAlignX(alignment); + }, + setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => { + await nativeRef.current?.setSubtitleAlignY(alignment); + }, + setSubtitleFontSize: async (size: number) => { + await nativeRef.current?.setSubtitleFontSize(size); + }, + setSubtitleBackgroundColor: async (color: string) => { + await nativeRef.current?.setSubtitleBackgroundColor(color); + }, + setSubtitleBorderStyle: async ( + style: "outline-and-shadow" | "background-box", + ) => { + await nativeRef.current?.setSubtitleBorderStyle(style); + }, + setSubtitleAssOverride: async (mode: "no" | "force") => { + await nativeRef.current?.setSubtitleAssOverride(mode); + }, + getAudioTracks: async () => { + return await nativeRef.current?.getAudioTracks(); + }, + setAudioTrack: async (trackId: number) => { + await nativeRef.current?.setAudioTrack(trackId); + }, + getCurrentAudioTrack: async () => { + return await nativeRef.current?.getCurrentAudioTrack(); + }, + setZoomedToFill: async (zoomed: boolean) => { + await nativeRef.current?.setZoomedToFill(zoomed); + }, + isZoomedToFill: async () => { + return await nativeRef.current?.isZoomedToFill(); + }, + getTechnicalInfo: async () => { + return await nativeRef.current?.getTechnicalInfo(); + }, + })); + + return ; + }, +); diff --git a/modules/index.ts b/modules/index.ts index 1f2b458f..0704a27d 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -7,6 +7,8 @@ export type { DownloadStartedEvent, } from "./background-downloader"; export { default as BackgroundDownloader } from "./background-downloader"; +// ExoPlayer (Android TV) +export { ExoPlayerView } from "./exoplayer-player"; // Glass Poster (tvOS 26+) export type { GlassPosterViewProps } from "./glass-poster"; export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster"; diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 17ee75de..c2817450 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -175,4 +175,28 @@ export type TechnicalInfo = { hwdec?: string; /** Estimated video output fps (mpv "estimated-vf-fps") */ estimatedVfFps?: number; + // ---- Extended fields (primarily ExoPlayer-backed; MPV may fill some) ---- + /** Derived HDR format: "SDR" | "HDR10" | "HDR10+" | "HLG" | null */ + hdrFormat?: string; + /** Color space, e.g. "BT.709" / "BT.2020" */ + colorSpace?: string; + /** Color range: "Limited" / "Full" */ + colorRange?: string; + /** Color transfer: "SDR" / "ST2084 (PQ)" / "HLG" */ + colorTransfer?: string; + /** Decoder path: "hardware" (MediaCodec) or "software" (FFmpeg extension) */ + decoderType?: string; + /** Instantiated decoder name, e.g. "c2.amlogic.hevc.decoder" */ + decoderName?: string; + /** Active audio channel count (2 = stereo, 6 = 5.1, 8 = 7.1) */ + audioChannels?: number; + /** Active audio sample rate in Hz */ + audioSampleRate?: number; + /** + * Raw codec tag from the container, e.g. "hev1.2.4.L153.B0". Encodes + * profile / tier / level / constraint bytes per ISO/IEC 14496-15. Power + * users can decode this manually; it's how Jellyfin's HEVC level cap + * (153 = Level 5.1) is checked against the file. + */ + videoCodecs?: string; }; diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx index fe1d39f3..f1b6d498 100644 --- a/providers/PlaySettingsProvider.tsx +++ b/providers/PlaySettingsProvider.tsx @@ -7,7 +7,7 @@ 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 { getActivePlayerType, settingsAtom } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { generateDeviceProfile } from "../utils/profiles/native"; import { apiAtom, userAtom } from "./JellyfinProvider"; @@ -78,10 +78,11 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({ } try { - // Generate device profile for MPV player + // Match the device profile to the actually-active player so the + // server picks codecs/containers the player can decode. const native = generateDeviceProfile({ platform: Platform.OS as "ios" | "android", - player: "mpv", + player: getActivePlayerType(settings), audioMode: settings.audioTranscodeMode, }); const data = await getStreamUrl({ diff --git a/translations/en.json b/translations/en.json index 3c4271b1..3be0585d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -199,6 +199,13 @@ "rewind_length": "Rewind length", "seconds_unit": "s" }, + "video_player": { + "title": "Video Player", + "exoplayer": "ExoPlayer", + "mpv": "MPV", + "exoplayer_note": "ExoPlayer does not support advanced ASS/SSA subtitle styling or horizontal subtitle alignment. Switch to MPV if you need those.", + "mpv_note": "MPV on TV does not currently pass HDR metadata to the display — HDR10/HDR10+ content is tone-mapped to SDR. Switch to ExoPlayer for HDR output." + }, "buffer": { "title": "Buffer settings", "cache_mode": "Cache mode", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 358550be..c1f92e76 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -171,11 +171,38 @@ export type HomeSectionLatestResolver = { includeItemTypes?: Array; }; -// Video player enum - currently only MPV is supported +// Video player enum. MPV is the universal default; ExoPlayer is an +// opt-in alternative on Android TV, selectable via settings.videoPlayer. export enum VideoPlayer { MPV = 0, + ExoPlayer = 1, } +/** + * Resolve the actually-active video player for the current settings. + * MPV is the default on every platform; users can opt into ExoPlayer on + * Android TV via settings.videoPlayer. Centralized here so the rule has + * one source of truth (used by VideoPlayerView, direct-player's device + * profile, and the TV settings UI). + */ +export const getActiveVideoPlayer = ( + settings: Pick | null | undefined, +): VideoPlayer => { + return settings?.videoPlayer ?? VideoPlayer.MPV; +}; + +/** + * Same selection as getActiveVideoPlayer but returns the lowercase + * player-type identifier that `generateDeviceProfile` expects. + */ +export const getActivePlayerType = ( + settings: Pick | null | undefined, +): "mpv" | "exoplayer" => { + return getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer + ? "exoplayer" + : "mpv"; +}; + // TV Typography scale presets export enum TVTypographyScale { Small = "small", @@ -218,6 +245,8 @@ export type Settings = { mediaListCollectionIds?: string[]; preferedLanguage?: string; searchEngine: "Marlin" | "Jellyfin" | "Streamystats"; + /** Video player backend. Defaults to MPV when unset (see getActiveVideoPlayer). */ + videoPlayer?: VideoPlayer; marlinServerUrl?: string; streamyStatsServerUrl?: string; streamyStatsMovieRecommendations?: boolean; @@ -315,6 +344,8 @@ export const defaultValues: Settings = { mediaListCollectionIds: [], preferedLanguage: undefined, searchEngine: "Jellyfin", + // videoPlayer intentionally undefined — resolved at runtime via + // getActiveVideoPlayer() so existing installs are unaffected. marlinServerUrl: "", streamyStatsServerUrl: "", streamyStatsMovieRecommendations: false, diff --git a/utils/profiles/native.ts b/utils/profiles/native.ts index 3a9336cd..e022b265 100644 --- a/utils/profiles/native.ts +++ b/utils/profiles/native.ts @@ -9,7 +9,7 @@ import MediaTypes from "../../constants/MediaTypes"; import { getSubtitleProfiles } from "./subtitles"; export type PlatformType = "ios" | "android"; -export type PlayerType = "mpv"; +export type PlayerType = "mpv" | "exoplayer"; export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough"; export interface ProfileOptions { @@ -63,6 +63,26 @@ const getAudioCodecProfile = (platform: PlatformType) => { }; }; +/** + * Resolves the MaxAudioChannels string for a given audio transcoding mode. + * Used by both the MPV and ExoPlayer profile branches — the channel-cap + * rule is player-agnostic (the player decodes; the cap just tells the + * server when to transcode down). + */ +const maxChannelsForMode = (audioMode: AudioTranscodeModeType): string => { + switch (audioMode) { + case "stereo": + return "2"; + case "5.1": + return "6"; + case "passthrough": + return "8"; + default: + // Auto: default to 5.1 (6 channels) + return "6"; + } +}; + /** * Gets the video audio codec configuration based on platform and audio mode. * @@ -89,35 +109,59 @@ const getVideoAudioCodecs = ( // MPV can decode all codecs - only channel count varies by mode const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`; - switch (audioMode) { - case "stereo": - // Limit to 2 channels - MPV will decode and downmix - return { - directPlayCodec: allCodecs, - maxAudioChannels: "2", - }; + return { + directPlayCodec: allCodecs, + maxAudioChannels: maxChannelsForMode(audioMode), + }; +}; - case "5.1": - // Limit to 6 channels - return { - directPlayCodec: allCodecs, - maxAudioChannels: "6", - }; +/** + * ExoPlayer (Media3 1.10.1) direct-play profile for Android TV. + * + * Codec set aligned with Media3's documented supported-formats list: + * - Video: H.263, H.264, H.265, VP8, VP9, AV1 + * - Audio: Vorbis, Opus, FLAC, ALAC, PCM, MP3, AAC, AC-3, E-AC-3, DTS, + * DTS-HD, TrueHD + * + * Hardware decode (MediaCodec) handles whatever the device ships with; + * the rest fall through to FFmpeg software decode via the Jellyfin-published + * `org.jellyfin.media3:media3-ffmpeg-decoder` extension wired up with + * `DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER` (see + * ExoPlayerView.kt:ensurePlayer). + * + * Cross-checked against the reference-device probe in + * docs/research/hdr-dv-atmos-tv-plan.md (Amlogic Android 14 TV; HDMI sink + * accepts AC3/EAC3 as bitstream and multichannel PCM up to 7.1 @ 192 kHz, + * so software-decoded DTS/DTS-HD/TrueHD reach the sink as PCM). + * + * Dolby Vision: the CodecProfile below uses `NotEquals VideoRangeType + * DOVI`, which in Jellyfin's semantics blocks ONLY pure Profile 5 + * (IPTPQc2 — the stream that renders purple/green without a DV-aware + * decoder). DV Profiles 7/8 with HDR10 or SDR base layers (Jellyfin + * reports these as `DOVIWithHDR10`, `DOVIWithHDR10Plus`, `DOVIWithEL`) + * are NOT blocked — Media3 1.9.1+ correctly falls back to the AVC/HEVC + * base layer. + * + * Containers limited to Media3's bundled extractors. FLV is intentionally + * absent — Media3 has no FLV extractor (MPV claims it via FFmpeg). + */ +const getExoPlayerDirectPlayProfile = () => { + const audioCodecs = + "vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd"; - case "passthrough": - // Allow up to 8 channels - for external DAC/receiver setups - return { - directPlayCodec: allCodecs, - maxAudioChannels: "8", - }; - - default: - // Auto mode: default to 5.1 (6 channels) - return { - directPlayCodec: allCodecs, - maxAudioChannels: "6", - }; - } + return { + video: { + Type: MediaTypes.Video, + Container: "mp4,mkv,webm,ts,mpegts,mov", + VideoCodec: "h263,h264,hevc,vp8,vp9,av1", + AudioCodec: audioCodecs, + }, + audio: { + Type: MediaTypes.Audio, + Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka", + AudioCodec: "vorbis,opus,flac,alac,pcm,mp3,aac", + }, + }; }; /** @@ -126,6 +170,63 @@ const getVideoAudioCodecs = ( export const generateDeviceProfile = (options: ProfileOptions = {}) => { const platform = (options.platform || Platform.OS) as PlatformType; const audioMode = options.audioMode || "auto"; + const player = options.player || "mpv"; + + // ExoPlayer branch — Media3 capabilities on Android TV. + if (player === "exoplayer" && platform === "android") { + const exoDirect = getExoPlayerDirectPlayProfile(); + + return { + Name: "1. ExoPlayer", + MaxStaticBitrate: 999_999_999, + MaxStreamingBitrate: 999_999_999, + CodecProfiles: [ + { + Type: MediaTypes.Video, + Codec: "h263,h264,hevc,vp8,vp9,av1", + }, + { + Type: MediaTypes.Video, + Codec: "hevc,h265", + Conditions: [ + { + Condition: "NotEquals", + Property: "VideoRangeType", + // Blocks ONLY pure DV Profile 5 (IPTPQc2). Profiles 7/8 with + // HDR10/SDR base layers fall through to Media3's HEVC fallback + // (1.9.1+). See getExoPlayerDirectPlayProfile doc above. + Value: "DOVI", + IsRequired: true, + }, + ], + }, + { + Type: MediaTypes.Audio, + Codec: "vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd", + }, + ], + DirectPlayProfiles: [exoDirect.video, exoDirect.audio], + TranscodingProfiles: [ + { + Type: MediaTypes.Video, + Context: "Streaming", + Protocol: "hls", + Container: "ts", + VideoCodec: "h264,hevc", + AudioCodec: "aac,mp3,ac3", + MaxAudioChannels: maxChannelsForMode(audioMode), + }, + ], + // Text-only subtitles for direct play. PGS delivered as Encode + // (burn-in) because Media3's PGS support is inconsistent. + SubtitleProfiles: [ + { Format: "srt", Method: "External" }, + { Format: "vtt", Method: "External" }, + { Format: "ttml", Method: "External" }, + { Format: "pgssub", Method: "Encode" }, + ], + }; + } const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs( platform, @@ -198,6 +299,3 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => { return profile; }; - -// Default export for backward compatibility -export default generateDeviceProfile();