Playback core: ExoPlayer and libVLC engines
This commit is contained in:
25
playback/engine-exo/build.gradle.kts
Normal file
25
playback/engine-exo/build.gradle.kts
Normal file
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.movies.playback.exo"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":playback:api"))
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.exoplayer.hls)
|
||||
implementation(libs.media3.ui)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
@file:OptIn(UnstableApi::class)
|
||||
|
||||
package ru.stenfredd.movies.playback.exo
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import ru.stenfredd.movies.playback.api.Codecs
|
||||
import ru.stenfredd.movies.playback.api.EngineError
|
||||
import ru.stenfredd.movies.playback.api.EngineEvent
|
||||
import ru.stenfredd.movies.playback.api.EngineState
|
||||
import ru.stenfredd.movies.playback.api.EngineStats
|
||||
import ru.stenfredd.movies.playback.api.LoadRequest
|
||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.movies.playback.api.embeddedOrdinal
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
|
||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
|
||||
override val id = ExoEngineProvider.ID
|
||||
|
||||
private val trackSelector = DefaultTrackSelector(context).apply {
|
||||
parameters = buildUponParameters()
|
||||
.setTunnelingEnabled(false)
|
||||
.setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val player: ExoPlayer = ExoPlayer.Builder(
|
||||
context,
|
||||
DefaultRenderersFactory(context)
|
||||
.setMediaCodecSelector(::decoderInfos)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
|
||||
)
|
||||
.setTrackSelector(trackSelector)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
|
||||
true,
|
||||
)
|
||||
.build()
|
||||
|
||||
private val _state = MutableStateFlow(EngineState())
|
||||
override val state = _state.asStateFlow()
|
||||
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val events = _events.asSharedFlow()
|
||||
private val _stats = MutableStateFlow(EngineStats())
|
||||
override val stats = _stats.asStateFlow()
|
||||
|
||||
override val positionMs get() = player.currentPosition
|
||||
override val durationMs get() = player.duration.takeIf { it != C.TIME_UNSET } ?: 0
|
||||
|
||||
private val surfaceView = SurfaceView(context)
|
||||
private val textureView = TextureView(context)
|
||||
private val aspectFrame = AspectRatioFrameLayout(context).apply {
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
private val subtitleView = SubtitleView(context).apply {
|
||||
setUserDefaultStyle()
|
||||
setUserDefaultTextSize()
|
||||
}
|
||||
|
||||
private var request: LoadRequest? = null
|
||||
|
||||
/** Profile 5 has no backward-compatible base layer: only a Dolby Vision decoder renders it correctly. */
|
||||
private var requiresDolbyVisionDecoder = false
|
||||
private var tracksApplied = false
|
||||
private var errorReported = false
|
||||
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) = updateState()
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) = updateState()
|
||||
override fun onRenderedFirstFrame() {
|
||||
_events.tryEmit(EngineEvent.FirstFrameRendered)
|
||||
}
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (videoSize.width > 0 && videoSize.height > 0) {
|
||||
aspectFrame.setAspectRatio(videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height)
|
||||
}
|
||||
}
|
||||
override fun onCues(cueGroup: CueGroup) = subtitleView.setCues(cueGroup.cues)
|
||||
override fun onTracksChanged(tracks: Tracks) = onTracks(tracks)
|
||||
override fun onPlayerError(error: PlaybackException) = reportError(map(error))
|
||||
}
|
||||
|
||||
private val analytics = object : AnalyticsListener {
|
||||
override fun onVideoDecoderInitialized(eventTime: AnalyticsListener.EventTime, decoderName: String, initializedTimestampMs: Long, initializationDurationMs: Long) {
|
||||
_stats.update { it.copy(videoDecoder = decoderName) }
|
||||
}
|
||||
override fun onAudioDecoderInitialized(eventTime: AnalyticsListener.EventTime, decoderName: String, initializedTimestampMs: Long, initializationDurationMs: Long) {
|
||||
_stats.update { it.copy(audioDecoder = decoderName) }
|
||||
}
|
||||
override fun onVideoInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) {
|
||||
_stats.update { it.copy(videoFormat = describeVideo(format)) }
|
||||
}
|
||||
override fun onAudioInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) {
|
||||
_stats.update { it.copy(audioFormat = "${format.sampleMimeType} ${format.channelCount}ch ${format.sampleRate}Hz") }
|
||||
}
|
||||
override fun onDroppedVideoFrames(eventTime: AnalyticsListener.EventTime, droppedFrames: Int, elapsedMs: Long) {
|
||||
_stats.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
|
||||
}
|
||||
override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) {
|
||||
val c = audioTrackConfig
|
||||
val kind = if (c.encoding == C.ENCODING_PCM_16BIT || c.encoding == C.ENCODING_PCM_FLOAT) "PCM (декодировано)" else "passthrough"
|
||||
_stats.update {
|
||||
it.copy(audioOutput = "$kind ${Codecs.encodingName(c.encoding)} ${c.sampleRate}Hz" + if (c.tunneling) " tunneled" else "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
player.addListener(listener)
|
||||
player.addAnalyticsListener(analytics)
|
||||
applyVideoPath()
|
||||
}
|
||||
|
||||
override val videoPathMode get() = pathMode
|
||||
override var usingGpuPath = false
|
||||
private set
|
||||
private var pathApplied = false
|
||||
|
||||
override fun setVideoPathMode(mode: VideoPathSwitchable.Mode) {
|
||||
pathMode = mode
|
||||
applyVideoPath()
|
||||
}
|
||||
|
||||
/**
|
||||
* On the Xiaomi TV the hardware video plane gets the TV's own "enhancement" (sharpening, contrast): compressed
|
||||
* SDR sources look visibly harsher than through the GPU (user A/B against VLC, 2026-10-12). AUTO therefore
|
||||
* composites SDR up to 1080p through the GPU, and keeps the video plane where it is required: HDR (the GPU
|
||||
* path is SDR only) and >1080p (the UI surface is 1080p, the GPU path would throw away resolution).
|
||||
*/
|
||||
private fun gpuPathFor(request: LoadRequest?): Boolean = when (pathMode) {
|
||||
VideoPathSwitchable.Mode.GPU -> true
|
||||
VideoPathSwitchable.Mode.VIDEO_PLANE -> false
|
||||
VideoPathSwitchable.Mode.AUTO -> request?.mediaSource?.videoStream?.let { v ->
|
||||
v.videoRange != VideoRange.HDR && (v.width ?: 0) <= 1920 && (v.height ?: 0) <= 1088
|
||||
} == true
|
||||
}
|
||||
|
||||
private fun applyVideoPath() {
|
||||
val gpu = gpuPathFor(request)
|
||||
if (pathApplied && gpu == usingGpuPath) return
|
||||
pathApplied = true
|
||||
usingGpuPath = gpu
|
||||
val (view, other) = if (gpu) textureView to surfaceView else surfaceView to textureView
|
||||
aspectFrame.removeView(other)
|
||||
if (view.parent == null) {
|
||||
aspectFrame.addView(view, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
|
||||
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
|
||||
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
|
||||
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
|
||||
}
|
||||
|
||||
override fun attachVideo(container: FrameLayout) {
|
||||
detachVideo()
|
||||
container.addView(aspectFrame, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER))
|
||||
container.addView(subtitleView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
|
||||
override fun detachVideo() {
|
||||
(aspectFrame.parent as? ViewGroup)?.removeView(aspectFrame)
|
||||
(subtitleView.parent as? ViewGroup)?.removeView(subtitleView)
|
||||
}
|
||||
|
||||
override fun load(request: LoadRequest) {
|
||||
this.request = request
|
||||
tracksApplied = false
|
||||
requiresDolbyVisionDecoder = request.mediaSource.videoStream?.videoRangeType == VideoRangeType.DOVI
|
||||
applyVideoPath()
|
||||
val subtitles = request.externalSubtitles.map { s ->
|
||||
MediaItem.SubtitleConfiguration.Builder(s.url.toUri())
|
||||
.setId("$EXTERNAL_PREFIX${s.streamIndex}")
|
||||
.setMimeType(subtitleMime(s.codec))
|
||||
.setLanguage(s.language)
|
||||
.setLabel(s.title)
|
||||
.build()
|
||||
}
|
||||
val item = MediaItem.Builder()
|
||||
.setUri(request.url)
|
||||
.setSubtitleConfigurations(subtitles)
|
||||
.apply { if (request.url.contains(".m3u8")) setMimeType(MimeTypes.APPLICATION_M3U8) }
|
||||
.build()
|
||||
player.setMediaItem(item, request.startPositionMs)
|
||||
player.prepare()
|
||||
}
|
||||
|
||||
/**
|
||||
* Without a Dolby Vision decoder media3 falls back to the base-layer codec (HEVC/AVC/AV1) and its HDR10/HDR10+/HLG
|
||||
* metadata. See [ExoEngineProvider.USE_DOLBY_VISION_DECODER] for why that is the default.
|
||||
*/
|
||||
private fun decoderInfos(mimeType: String, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean): List<MediaCodecInfo> {
|
||||
if (mimeType == MimeTypes.VIDEO_DOLBY_VISION && !ExoEngineProvider.USE_DOLBY_VISION_DECODER && !requiresDolbyVisionDecoder) {
|
||||
return emptyList()
|
||||
}
|
||||
return MediaCodecUtil.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder)
|
||||
}
|
||||
|
||||
override val isSeekable get() = player.currentTimeline.isEmpty || player.isCurrentMediaItemSeekable
|
||||
|
||||
override fun play() = player.play()
|
||||
override fun pause() = player.pause()
|
||||
override fun seekTo(positionMs: Long) = player.seekTo(positionMs)
|
||||
|
||||
override fun selectAudio(streamIndex: Int) {
|
||||
val req = request ?: return
|
||||
val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.AUDIO) ?: return
|
||||
selectEmbedded(C.TRACK_TYPE_AUDIO, ordinal)
|
||||
}
|
||||
|
||||
override fun selectSubtitle(streamIndex: Int?) {
|
||||
val req = request ?: return
|
||||
val builder = trackSelector.buildUponParameters().clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
if (streamIndex == null) {
|
||||
trackSelector.parameters = builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build()
|
||||
return
|
||||
}
|
||||
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
val textGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
|
||||
val external = textGroups.firstOrNull { it.getTrackFormat(0).id?.endsWith("$EXTERNAL_PREFIX$streamIndex") == true }
|
||||
val group = external ?: req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let { ordinal ->
|
||||
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
|
||||
}
|
||||
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
||||
trackSelector.parameters = builder.build()
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
detachVideo()
|
||||
player.removeListener(listener)
|
||||
player.removeAnalyticsListener(analytics)
|
||||
player.release()
|
||||
}
|
||||
|
||||
private fun selectEmbedded(type: Int, ordinal: Int) {
|
||||
val group = player.currentTracks.groups.filter { it.type == type }.getOrNull(ordinal) ?: return
|
||||
trackSelector.parameters = trackSelector.buildUponParameters()
|
||||
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun onTracks(tracks: Tracks) {
|
||||
val req = request ?: return
|
||||
if (!tracksApplied && tracks.groups.isNotEmpty()) {
|
||||
tracksApplied = true
|
||||
// A transcode already contains only the selected streams.
|
||||
if (req.playMethod == PlayMethod.DIRECT_PLAY) {
|
||||
req.audioStreamIndex?.let(::selectAudio)
|
||||
selectSubtitle(req.subtitleStreamIndex)
|
||||
} else if (req.subtitleStreamIndex == null) {
|
||||
selectSubtitle(null)
|
||||
}
|
||||
}
|
||||
// Decoders often under-report their limits (e.g. HEVC L5.1 High tier on MediaTek), so only a track that
|
||||
// is unsupported even when exceeding the declared capabilities is treated as unplayable.
|
||||
val video = tracks.groups.filter { it.type == C.TRACK_TYPE_VIDEO }
|
||||
if (video.isNotEmpty() && video.none { it.isSupported(true) }) {
|
||||
reportError(EngineError(EngineError.Kind.DECODER, "Видеодорожка не поддерживается: ${describeVideo(video.first().getTrackFormat(0))}"))
|
||||
}
|
||||
val audio = tracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
|
||||
if (audio.isNotEmpty() && audio.none { it.isSupported(true) }) {
|
||||
reportError(EngineError(EngineError.Kind.DECODER, "Аудиодорожка не поддерживается: ${audio.first().getTrackFormat(0).sampleMimeType}"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateState() {
|
||||
val phase = when (player.playbackState) {
|
||||
Player.STATE_BUFFERING -> EngineState.Phase.BUFFERING
|
||||
Player.STATE_READY -> EngineState.Phase.READY
|
||||
Player.STATE_ENDED -> EngineState.Phase.ENDED
|
||||
else -> EngineState.Phase.IDLE
|
||||
}
|
||||
_state.value = EngineState(phase, player.playWhenReady)
|
||||
}
|
||||
|
||||
private fun reportError(error: EngineError) {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
Timber.w(error.cause, "Exo error: %s", error.message)
|
||||
_events.tryEmit(EngineEvent.Error(error))
|
||||
}
|
||||
|
||||
private fun map(e: PlaybackException): EngineError {
|
||||
val kind = when (e.errorCode) {
|
||||
in 4000..4999 -> EngineError.Kind.DECODER
|
||||
in 3000..3999 -> EngineError.Kind.FORMAT
|
||||
in 5000..5999 -> EngineError.Kind.AUDIO_OUTPUT
|
||||
in 2000..2999 -> EngineError.Kind.NETWORK
|
||||
else -> EngineError.Kind.UNKNOWN
|
||||
}
|
||||
return EngineError(kind, "${e.errorCodeName}: ${e.message}", e)
|
||||
}
|
||||
|
||||
private fun describeVideo(f: Format) = buildString {
|
||||
append(f.sampleMimeType)
|
||||
f.codecs?.let { append(" ").append(it) }
|
||||
if (f.width > 0) append(" ${f.width}x${f.height}")
|
||||
if (f.frameRate > 0) append(" %.3ffps".format(f.frameRate))
|
||||
f.colorInfo?.let { append(" ").append(it) }
|
||||
}
|
||||
|
||||
private fun subtitleMime(codec: String?) = when (codec?.lowercase()) {
|
||||
"ass", "ssa" -> MimeTypes.TEXT_SSA
|
||||
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
|
||||
"ttml" -> MimeTypes.APPLICATION_TTML
|
||||
else -> MimeTypes.APPLICATION_SUBRIP
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Chosen in the player menu; kept for the process lifetime (no settings screen yet). */
|
||||
private var pathMode = VideoPathSwitchable.Mode.AUTO
|
||||
|
||||
private const val EXTERNAL_PREFIX = "ext:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.stenfredd.movies.playback.exo
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import ru.stenfredd.movies.playback.api.EngineProvider
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ExoEngineModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindProvider(provider: ExoEngineProvider): EngineProvider
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package ru.stenfredd.movies.playback.exo
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.view.Display.HdrCapabilities
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionValue
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
|
||||
import ru.stenfredd.movies.playback.api.Codecs
|
||||
import ru.stenfredd.movies.playback.api.DeviceCapabilities
|
||||
import ru.stenfredd.movies.playback.api.EngineId
|
||||
import ru.stenfredd.movies.playback.api.EngineProvider
|
||||
import ru.stenfredd.movies.playback.api.Loss
|
||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||
import ru.stenfredd.movies.playback.api.Verdict
|
||||
import ru.stenfredd.movies.playback.api.stream
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import javax.inject.Inject
|
||||
|
||||
class ExoEngineProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : EngineProvider {
|
||||
override val id = ID
|
||||
override val displayName = "ExoPlayer"
|
||||
override val priority = 0
|
||||
|
||||
override fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict {
|
||||
val no = mutableListOf<String>()
|
||||
val losses = mutableSetOf<Loss>()
|
||||
val notes = mutableListOf<String>()
|
||||
|
||||
val containers = source.container.orEmpty().lowercase().split(',').map(String::trim)
|
||||
if (containers.none { it in CONTAINERS }) no += "контейнер ${source.container}"
|
||||
|
||||
source.videoStream?.let { v ->
|
||||
val mime = Codecs.videoMime(v.codec)
|
||||
when {
|
||||
mime == null -> no += "видеокодек ${v.codec}"
|
||||
v.codec.equals("h264", true) && (v.bitDepth ?: 8) > 8 &&
|
||||
!device.hasHardwareProfile(mime, CodecProfileLevel.AVCProfileHigh10) -> no += "H.264 10-bit без аппаратного декодера"
|
||||
!device.hasHardwareVideo(mime, v.width, v.height) ->
|
||||
if (device.videoDecoders.any { it.mime.equals(mime, true) }) losses += Loss.SOFTWARE_DECODE_HEAVY
|
||||
else no += "нет декодера ${v.codec} ${v.width}x${v.height}"
|
||||
}
|
||||
evaluateRange(v.videoRangeType, v.dvProfile, device, no, losses, notes)
|
||||
if (v.isInterlaced) losses += Loss.NO_DEINTERLACE
|
||||
}
|
||||
|
||||
source.stream(selection.audioStreamIndex)?.let { a ->
|
||||
val mime = Codecs.audioMime(a.codec)
|
||||
val passthrough = Codecs.passthroughEncoding(a.codec, a.profile)?.let { it in device.passthroughEncodings } == true
|
||||
if (!passthrough && (mime == null || !device.hasAudioDecoder(mime))) no += "аудио ${a.codec}"
|
||||
if (passthrough) notes += "${a.codec}: passthrough"
|
||||
}
|
||||
|
||||
source.stream(selection.subtitleStreamIndex)?.let { s ->
|
||||
when (s.codec?.lowercase()) {
|
||||
"ass", "ssa" -> losses += Loss.ASS_SIMPLIFIED
|
||||
"pgssub", "dvdsub", "dvbsub" -> if (s.isExternal) no += "внешние ${s.codec}"
|
||||
}
|
||||
}
|
||||
|
||||
return when {
|
||||
no.isNotEmpty() -> Verdict.No(no)
|
||||
losses.isNotEmpty() -> Verdict.Degraded(losses, notes)
|
||||
else -> Verdict.Direct(notes)
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateRange(
|
||||
range: VideoRangeType,
|
||||
dvProfile: Int?,
|
||||
device: DeviceCapabilities,
|
||||
no: MutableList<String>,
|
||||
losses: MutableSet<Loss>,
|
||||
notes: MutableList<String>,
|
||||
) {
|
||||
val dvCapable = dvProfileConstant(dvProfile)?.let { device.hasHardwareProfile(DV_MIME, it) } == true &&
|
||||
device.displaySupportsHdr(HdrCapabilities.HDR_TYPE_DOLBY_VISION)
|
||||
// Profiles with a compatible base layer are decoded as that base layer unless the DV decoder is enabled.
|
||||
val dv = dvCapable && (USE_DOLBY_VISION_DECODER || range == VideoRangeType.DOVI)
|
||||
fun base(type: Int) {
|
||||
if (!device.displaySupportsHdr(type)) losses += Loss.HDR_LOST
|
||||
}
|
||||
when (range) {
|
||||
VideoRangeType.DOVI -> if (!dv) no += "Dolby Vision P$dvProfile без DV-декодера"
|
||||
VideoRangeType.DOVI_WITH_EL, VideoRangeType.DOVI_WITH_ELHDR10_PLUS ->
|
||||
if (!dv) no += "Dolby Vision P7 без DV-декодера (media3 не откатывается на HEVC)"
|
||||
VideoRangeType.DOVI_WITH_HDR10, VideoRangeType.DOVI_WITH_HLG, VideoRangeType.DOVI_WITH_SDR -> if (!dv) {
|
||||
losses += Loss.DV_TO_BASE_LAYER
|
||||
if (range != VideoRangeType.DOVI_WITH_SDR) base(if (range == VideoRangeType.DOVI_WITH_HLG) HdrCapabilities.HDR_TYPE_HLG else HdrCapabilities.HDR_TYPE_HDR10)
|
||||
}
|
||||
VideoRangeType.DOVI_WITH_HDR10_PLUS -> {
|
||||
if (!dv) losses += Loss.DV_TO_BASE_LAYER
|
||||
notes += "DV+HDR10+: известный баг media3 на MediaTek (#1895)"
|
||||
}
|
||||
VideoRangeType.HDR10 -> base(HdrCapabilities.HDR_TYPE_HDR10)
|
||||
VideoRangeType.HDR10_PLUS -> base(HdrCapabilities.HDR_TYPE_HDR10)
|
||||
VideoRangeType.HLG -> base(HdrCapabilities.HDR_TYPE_HLG)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun deviceProfile(device: DeviceCapabilities): DeviceProfile {
|
||||
val videoCodecs = VIDEO_CODECS.filter { c -> Codecs.videoMime(c)?.let { m -> device.videoDecoders.any { it.mime.equals(m, true) } } == true }
|
||||
val audioCodecs = AUDIO_CODECS.filter { c ->
|
||||
Codecs.audioMime(c)?.let(device::hasAudioDecoder) == true ||
|
||||
Codecs.passthroughEncoding(c, null)?.let { it in device.passthroughEncodings } == true
|
||||
}
|
||||
val hasDv = device.hardwareVideoDecoders(DV_MIME).isNotEmpty() && device.displaySupportsHdr(HdrCapabilities.HDR_TYPE_DOLBY_VISION)
|
||||
val hasP7 = device.hasHardwareProfile(DV_MIME, CodecProfileLevel.DolbyVisionProfileDvheDtb)
|
||||
val ranges = buildList {
|
||||
add(VideoRangeType.SDR)
|
||||
add(VideoRangeType.HDR10)
|
||||
add(VideoRangeType.HDR10_PLUS)
|
||||
add(VideoRangeType.HLG)
|
||||
add(VideoRangeType.DOVI_WITH_HDR10)
|
||||
add(VideoRangeType.DOVI_WITH_HLG)
|
||||
add(VideoRangeType.DOVI_WITH_SDR)
|
||||
add(VideoRangeType.DOVI_WITH_HDR10_PLUS)
|
||||
if (hasDv) add(VideoRangeType.DOVI)
|
||||
if (hasDv && hasP7) {
|
||||
add(VideoRangeType.DOVI_WITH_EL)
|
||||
add(VideoRangeType.DOVI_WITH_ELHDR10_PLUS)
|
||||
}
|
||||
}.map { it.serialName }
|
||||
val avcHigh10 = device.hasHardwareProfile("video/avc", CodecProfileLevel.AVCProfileHigh10)
|
||||
|
||||
return buildDeviceProfile {
|
||||
name = "Movies / ExoPlayer"
|
||||
maxStreamingBitrate = MAX_BITRATE
|
||||
maxStaticBitrate = MAX_BITRATE
|
||||
directPlayProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
container(*CONTAINERS.toTypedArray())
|
||||
videoCodec(*videoCodecs.toTypedArray())
|
||||
audioCodec(*audioCodecs.toTypedArray())
|
||||
}
|
||||
if (!avcHigh10) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = "h264"
|
||||
conditions { ProfileConditionValue.VIDEO_BIT_DEPTH lowerThanOrEquals 8 }
|
||||
}
|
||||
}
|
||||
for (codec in listOf("hevc", "av1", "vp9")) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
this.codec = codec
|
||||
conditions { ProfileConditionValue.VIDEO_RANGE_TYPE inCollection ranges }
|
||||
}
|
||||
}
|
||||
transcodingProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
context = EncodingContext.STREAMING
|
||||
protocol = MediaStreamProtocol.HLS
|
||||
container = "mp4"
|
||||
videoCodec("hevc", "h264")
|
||||
audioCodec("aac", "ac3", "eac3")
|
||||
minSegments = 1
|
||||
breakOnNonKeyFrames = false
|
||||
}
|
||||
for (format in listOf("srt", "subrip", "ass", "ssa", "pgssub", "dvdsub", "dvbsub", "mov_text", "vtt", "webvtt")) {
|
||||
subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
|
||||
}
|
||||
for (format in listOf("srt", "subrip", "ass", "ssa", "vtt", "webvtt", "ttml")) {
|
||||
subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun create(device: DeviceCapabilities): PlaybackEngine = ExoEngine(context)
|
||||
|
||||
companion object {
|
||||
val ID = EngineId("exo")
|
||||
private const val DV_MIME = "video/dolby-vision"
|
||||
|
||||
/**
|
||||
* On the Xiaomi MT9689 the DV decoder (`c2.mtk.dvhe.st`) plays, but the picture is visibly duller than the
|
||||
* same file's HDR10/HDR10+ base layer (user comparison against VLC, 2026-10-12): the TV apparently does not
|
||||
* switch into Dolby Vision mode. Until that is solved (tunneling? see NEXT_SESSION.md §4.3) profiles with a
|
||||
* base layer (8.x, 9, 10) are played as HEVC/AVC/AV1 HDR. Profile 5 always needs the DV decoder.
|
||||
*/
|
||||
const val USE_DOLBY_VISION_DECODER = false
|
||||
private const val MAX_BITRATE = 400_000_000
|
||||
private val CONTAINERS = listOf("mkv", "webm", "mp4", "m4v", "mov", "ts", "mpegts", "avi", "mpeg", "ogg", "flv", "3gp")
|
||||
private val VIDEO_CODECS = listOf("h264", "hevc", "vp8", "vp9", "av1", "mpeg2video", "mpeg4")
|
||||
private val AUDIO_CODECS = listOf("aac", "mp3", "mp2", "ac3", "eac3", "truehd", "dts", "flac", "opus", "vorbis", "alac", "pcm_s16le", "pcm_s24le")
|
||||
|
||||
private fun dvProfileConstant(profile: Int?): Int? = when (profile) {
|
||||
4 -> CodecProfileLevel.DolbyVisionProfileDvheDtr
|
||||
5 -> CodecProfileLevel.DolbyVisionProfileDvheStn
|
||||
7 -> CodecProfileLevel.DolbyVisionProfileDvheDtb
|
||||
8 -> CodecProfileLevel.DolbyVisionProfileDvheSt
|
||||
9 -> CodecProfileLevel.DolbyVisionProfileDvavSe
|
||||
10 -> CodecProfileLevel.DolbyVisionProfileDvav110
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user