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>
This commit is contained in:
Lance Chant
2026-07-01 13:07:35 +02:00
parent 28a75a2b8c
commit faa250bfdd
19 changed files with 1735 additions and 82 deletions

View File

@@ -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<AudioTranscodeMode>[] = useMemo(
() => [
@@ -391,6 +413,23 @@ export default function SettingsTV() {
[t, currentVoDriver],
);
// Video player backend options (Android TV only)
const videoPlayerOptions: TVOptionItem<VideoPlayer>[] = 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<TVTypographyScale>[] = 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 */}
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
{/* Video Player selector — Android TV only */}
{isAndroidTv && (
<>
<TVSettingsOptionButton
label={t("home.settings.video_player.title")}
value={videoPlayerLabel}
onPress={() =>
showOptions({
title: t("home.settings.video_player.title"),
options: videoPlayerOptions,
onSelect: (value) => updateSettings({ videoPlayer: value }),
})
}
/>
{!isMpv && (
<Text style={playerNoteStyle}>
{t("home.settings.video_player.exoplayer_note")}
</Text>
)}
{isMpv && (
<Text style={playerNoteStyle}>
{t("home.settings.video_player.mpv_note")}
</Text>
)}
</>
)}
<TVSettingsOptionButton
label={t("home.settings.audio.transcode_mode.title")}
value={audioTranscodeLabel}
@@ -662,20 +734,23 @@ export default function SettingsTV() {
updateSettings({ mpvSubtitleMarginY: newValue });
}}
/>
<TVSettingsOptionButton
label='Horizontal Alignment'
value={alignXLabel}
onPress={() =>
showOptions({
title: "Horizontal Alignment",
options: alignXOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignX: value as "left" | "center" | "right",
}),
})
}
/>
{isMpv && (
<TVSettingsOptionButton
label='Horizontal Alignment'
value={alignXLabel}
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
onPress={() =>
showOptions({
title: "Horizontal Alignment",
options: alignXOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignX: value as "left" | "center" | "right",
}),
})
}
/>
)}
<TVSettingsOptionButton
label='Vertical Alignment'
value={alignYLabel}
@@ -748,19 +823,24 @@ export default function SettingsTV() {
}
/>
{/* Video Output Section */}
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
<TVSettingsOptionButton
label={t("home.settings.vo_driver.vo_mode")}
value={voDriverLabel}
onPress={() =>
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 && (
<>
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
<TVSettingsOptionButton
label={t("home.settings.vo_driver.vo_mode")}
value={voDriverLabel}
onPress={() =>
showOptions({
title: t("home.settings.vo_driver.vo_mode"),
options: voDriverOptions,
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
})
}
/>
</>
)}
<TVSettingsStepper
label={t("home.settings.buffer.buffer_duration")}
value={settings.mpvCacheSeconds ?? 10}

View File

@@ -29,6 +29,7 @@ import {
PlaybackSpeedScope,
updatePlaybackSpeedSettings,
} from "@/components/video-player/controls/utils/playback-speed-settings";
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
@@ -40,7 +41,6 @@ import {
type MpvOnErrorEventPayload,
type MpvOnPlaybackStateChangePayload,
type MpvOnProgressEventPayload,
MpvPlayerView,
type MpvPlayerViewRef,
type MpvVideoSource,
} from "@/modules";
@@ -51,7 +51,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import { getActivePlayerType, useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
@@ -364,7 +364,13 @@ export default function DirectPlayerPage() {
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(),
// Match the device profile to the player that will render the
// stream so the server picks a codec/container the player can
// actually decode.
deviceProfile: generateDeviceProfile({
player: getActivePlayerType(settings),
audioMode: settings.audioTranscodeMode,
}),
});
if (!res) return null;
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
@@ -1277,7 +1283,7 @@ export default function DirectPlayerPage() {
justifyContent: "center",
}}
>
<MpvPlayerView
<VideoPlayerView
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}

View File

@@ -44,8 +44,10 @@ export interface TVNextEpisodeCountdownProps {
playButtonRef?: RNView | null;
}
// Position constants
const BOTTOM_WITH_CONTROLS = scaleSize(300);
// Position constants — kept in sync with TVSkipSegmentCard (the two are
// mutually exclusive). See TVSkipSegmentCard for the BOTTOM_WITH_CONTROLS
// rationale (220 sits just above the controls bar; 300 floated too high).
const BOTTOM_WITH_CONTROLS = scaleSize(220);
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({

View File

@@ -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<TVSkipSegmentCardProps> = ({
show,

View File

@@ -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 <Player ref={ref} {...props} />;
});

View File

@@ -1129,7 +1129,16 @@ export const Controls: FC<Props> = ({
{/* Skip intro card */}
<TVSkipSegmentCard
show={showSkipButton && !isCountdownActive}
onPress={skipIntro}
onPress={() => {
// 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<Props> = ({
(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}

View File

@@ -213,13 +213,10 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = 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<TechnicalInfoOverlayProps> = memo(
<Text style={textStyle}>
{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)}`
: ""}
</Text>
)}
{info?.videoCodec && (
@@ -319,8 +320,15 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info?.audioCodec && (
<Text style={textStyle}>
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`
: ""}
</Text>
)}
@@ -339,6 +347,17 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
: "N/A"}
</Text>
)}
{(info?.colorSpace || info?.colorRange || info?.colorTransfer) && (
<Text style={textStyle}>
Color:
{[info.colorSpace, info.colorRange, info.colorTransfer]
.filter(Boolean)
.join(" / ")}
</Text>
)}
{info?.videoCodecs && (
<Text style={textStyle}>Codec tag: {info.videoCodecs}</Text>
)}
{info?.cacheSeconds !== undefined && (
<Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s
@@ -356,6 +375,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info.hwdec ? ` / ${info.hwdec}` : ""}
</Text>
)}
{info?.decoderName && (
<Text style={textStyle}>
Decoder: {info.decoderName}
{info.decoderType ? ` (${info.decoderType})` : ""}
</Text>
)}
{info?.estimatedVfFps !== undefined && (
<Text style={textStyle}>
Output FPS: {info.estimatedVfFps.toFixed(2)}

View File

@@ -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"
}

View File

@@ -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<String, Any?>? ->
if (source == null) return@Prop
val urlString = source["url"] as? String ?: return@Prop
@Suppress("UNCHECKED_CAST")
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
val config = VideoLoadConfig(
url = urlString,
headers = source["headers"] as? Map<String, String>,
externalSubtitles = source["externalSubtitles"] as? List<String>,
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<String, String>? ->
// 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")
}
}
}

View File

@@ -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<String, String>? = null,
val externalSubtitles: List<String>? = 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<TrackEntry> = emptyList()
private var audioTrackList: List<TrackEntry> = 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<String, Any>())
}
}
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<TrackEntry>()
val audios = mutableListOf<TrackEntry>()
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<Map<String, Any>> {
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<Map<String, Any>> {
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<String, Any> {
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<String, Any>()
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()
}
}

View File

@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
}
}

View File

@@ -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";

View File

@@ -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<MpvPlayerViewProps & { ref?: any }> =
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<MpvPlayerViewRef, MpvPlayerViewProps>(
function ExoPlayerView(props, ref) {
const nativeRef = useRef<any>(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 <NativeView ref={nativeRef} {...props} />;
},
);

View File

@@ -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";

View File

@@ -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;
};

View File

@@ -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({

View File

@@ -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",

View File

@@ -171,11 +171,38 @@ export type HomeSectionLatestResolver = {
includeItemTypes?: Array<BaseItemKind>;
};
// 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<Settings, "videoPlayer"> | 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<Settings, "videoPlayer"> | 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,

View File

@@ -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();