Playback core: ExoPlayer and libVLC engines

This commit is contained in:
2026-10-12 20:26:56 +03:00
commit dec4b44571
47 changed files with 4011 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "ru.stenfredd.movies.playback.api"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
api(libs.jellyfin.sdk)
api(libs.kotlinx.coroutines.android)
}

View File

@@ -0,0 +1,62 @@
package ru.stenfredd.movies.playback.api
import android.media.AudioFormat
/** Mapping between Jellyfin (ffprobe) codec names and Android MIME types / encodings. */
object Codecs {
fun videoMime(codec: String?): String? = when (codec?.lowercase()) {
"h264", "avc" -> "video/avc"
"hevc", "h265" -> "video/hevc"
"vp8" -> "video/x-vnd.on2.vp8"
"vp9" -> "video/x-vnd.on2.vp9"
"av1" -> "video/av01"
"mpeg2video", "mpeg1video" -> "video/mpeg2"
"mpeg4" -> "video/mp4v-es"
"h263" -> "video/3gpp"
"vc1" -> "video/wvc1"
else -> null
}
fun audioMime(codec: String?): String? = when (codec?.lowercase()) {
"aac" -> "audio/mp4a-latm"
"mp3" -> "audio/mpeg"
"mp2" -> "audio/mpeg-L2"
"ac3" -> "audio/ac3"
"eac3" -> "audio/eac3"
"truehd" -> "audio/true-hd"
"dts" -> "audio/vnd.dts"
"flac" -> "audio/flac"
"opus" -> "audio/opus"
"vorbis" -> "audio/vorbis"
"alac" -> "audio/alac"
"pcm_s16le", "pcm_s24le", "pcm_s16be", "pcm_s24be" -> "audio/raw"
else -> null
}
/** Encoding that can be passed through to the audio output, for compressed surround formats. */
fun passthroughEncoding(codec: String?, profile: String?): Int? = when (codec?.lowercase()) {
"ac3" -> AudioFormat.ENCODING_AC3
"eac3" -> if (profile?.contains("Atmos", true) == true) AudioFormat.ENCODING_E_AC3_JOC else AudioFormat.ENCODING_E_AC3
"truehd" -> AudioFormat.ENCODING_DOLBY_TRUEHD
"dts" -> if (profile?.contains("HD", true) == true || profile?.contains("X", false) == true) {
AudioFormat.ENCODING_DTS_HD
} else {
AudioFormat.ENCODING_DTS
}
else -> null
}
fun encodingName(encoding: Int): String = when (encoding) {
AudioFormat.ENCODING_AC3 -> "AC3"
AudioFormat.ENCODING_E_AC3 -> "E-AC3"
AudioFormat.ENCODING_E_AC3_JOC -> "E-AC3 JOC"
AudioFormat.ENCODING_DOLBY_TRUEHD -> "TrueHD"
AudioFormat.ENCODING_DTS -> "DTS"
AudioFormat.ENCODING_DTS_HD -> "DTS-HD"
AudioFormat.ENCODING_AC4 -> "AC4"
AudioFormat.ENCODING_DOLBY_MAT -> "Dolby MAT"
AudioFormat.ENCODING_PCM_16BIT -> "PCM 16"
AudioFormat.ENCODING_PCM_FLOAT -> "PCM float"
else -> "enc#$encoding"
}
}

View File

@@ -0,0 +1,43 @@
package ru.stenfredd.movies.playback.api
/** Snapshot of what the device can decode and output. Probed once by `:playback:core`. */
data class DeviceCapabilities(
val manufacturer: String,
val model: String,
val soc: String,
val sdkInt: Int,
val abis: List<String>,
val videoDecoders: List<DecoderInfo>,
val audioDecoders: List<DecoderInfo>,
/** `Display.HdrCapabilities.HDR_TYPE_*`. */
val displayHdrTypes: Set<Int>,
val displayModes: List<String>,
/** `AudioFormat.ENCODING_*` that can be sent to the output undecoded. */
val passthroughEncodings: Set<Int>,
) {
fun hardwareVideoDecoders(mime: String) =
videoDecoders.filter { it.hardware && it.mime.equals(mime, ignoreCase = true) }
fun hasHardwareVideo(mime: String, width: Int? = null, height: Int? = null) =
hardwareVideoDecoders(mime).any { d ->
(width == null || height == null) || (width <= d.maxWidth && height <= d.maxHeight) ||
(height <= d.maxWidth && width <= d.maxHeight)
}
fun hasHardwareProfile(mime: String, profile: Int) =
hardwareVideoDecoders(mime).any { profile in it.profiles }
fun hasAudioDecoder(mime: String) = audioDecoders.any { it.mime.equals(mime, ignoreCase = true) }
fun displaySupportsHdr(type: Int) = type in displayHdrTypes
}
data class DecoderInfo(
val name: String,
val mime: String,
val hardware: Boolean,
val maxWidth: Int,
val maxHeight: Int,
/** `MediaCodecInfo.CodecProfileLevel` profile constants. */
val profiles: Set<Int>,
)

View File

@@ -0,0 +1,46 @@
package ru.stenfredd.movies.playback.api
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaSourceInfo
/**
* Plugin entry point of an engine module. Bound into a Hilt `Set<EngineProvider>` with `@IntoSet`,
* so adding an engine means adding a module, nothing else changes.
*/
interface EngineProvider {
val id: EngineId
val displayName: String
/** Lower value wins when two engines are equally good. */
val priority: Int
/** Can this engine play [source] with [selection] directly on this device, and at what cost? */
fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict
/** Profile sent to the server in PlaybackInfo when this engine is used. */
fun deviceProfile(device: DeviceCapabilities): DeviceProfile
fun create(device: DeviceCapabilities): PlaybackEngine
}
data class StreamSelection(
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
)
sealed interface Verdict {
data class Direct(val notes: List<String> = emptyList()) : Verdict
data class Degraded(val losses: Set<Loss>, val notes: List<String> = emptyList()) : Verdict
data class No(val reasons: List<String>) : Verdict
}
enum class Loss(val weight: Int, val label: String) {
HDR_LOST(100, "HDR будет потерян"),
SOFTWARE_DECODE_HEAVY(40, "тяжёлое программное декодирование"),
SOFTWARE_DECODE(15, "программное декодирование"),
DV_TO_BASE_LAYER(30, "Dolby Vision → базовый слой"),
HDR_UNVERIFIED(25, "HDR-вывод не проверен"),
ASS_SIMPLIFIED(20, "ASS упрощён"),
/** Small: TV SoCs usually deinterlace in the display pipeline after the hardware decoder. */
NO_DEINTERLACE(5, "без деинтерлейса в плеере"),
}

View File

@@ -0,0 +1,29 @@
package ru.stenfredd.movies.playback.api
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
private val attachedPictureCodecs = setOf("mjpeg", "png", "bmp", "gif", "webp")
/** Main video stream, ignoring cover art that containers expose as a video stream. */
val MediaSourceInfo.videoStream: MediaStream?
get() = mediaStreams.orEmpty().firstOrNull {
it.type == MediaStreamType.VIDEO && it.codec?.lowercase() !in attachedPictureCodecs
}
fun MediaSourceInfo.stream(index: Int?): MediaStream? =
index?.let { i -> mediaStreams.orEmpty().firstOrNull { it.index == i } }
fun MediaSourceInfo.streams(type: MediaStreamType) = mediaStreams.orEmpty().filter { it.type == type }
/**
* Engines number embedded tracks per type in container order; Jellyfin uses the global ffprobe index.
* Returns the 0-based position of [index] among embedded streams of the same type, or null.
*/
fun MediaSourceInfo.embeddedOrdinal(index: Int, type: MediaStreamType): Int? {
val embedded = streams(type).filter { !it.isExternal }.let { list ->
if (type == MediaStreamType.VIDEO) list.filter { it.codec?.lowercase() !in attachedPictureCodecs } else list
}
return embedded.indexOfFirst { it.index == index }.takeIf { it >= 0 }
}

View File

@@ -0,0 +1,72 @@
package ru.stenfredd.movies.playback.api
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlayMethod
@JvmInline
value class EngineId(val value: String) {
override fun toString() = value
}
data class EngineState(
val phase: Phase = Phase.IDLE,
val playWhenReady: Boolean = false,
) {
enum class Phase { IDLE, BUFFERING, READY, ENDED }
val isPlaying get() = phase == Phase.READY && playWhenReady
}
sealed interface EngineEvent {
data object FirstFrameRendered : EngineEvent
data class Error(val error: EngineError) : EngineEvent
}
data class EngineError(
val kind: Kind,
val message: String,
val cause: Throwable? = null,
) {
enum class Kind {
/** Decoder could not be created or crashed: another engine may succeed. */
DECODER,
/** Container/stream format not understood: another engine may succeed. */
FORMAT,
AUDIO_OUTPUT,
NETWORK,
NO_VIDEO_OUTPUT,
UNKNOWN,
}
val worthFallback get() = kind != Kind.NETWORK
}
data class ExternalSubtitle(
val streamIndex: Int,
val url: String,
val codec: String?,
val language: String?,
val title: String?,
)
data class LoadRequest(
val url: String,
val playMethod: PlayMethod,
/** Source as returned by PlaybackInfo, used to map Jellyfin stream indexes to engine tracks. */
val mediaSource: MediaSourceInfo,
val startPositionMs: Long,
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
val externalSubtitles: List<ExternalSubtitle>,
)
/** Diagnostics shown in the debug overlay. Every field is optional: engines fill what they know. */
data class EngineStats(
val videoDecoder: String? = null,
val videoFormat: String? = null,
val audioDecoder: String? = null,
val audioFormat: String? = null,
val audioOutput: String? = null,
val droppedFrames: Int = 0,
val extra: Map<String, String> = emptyMap(),
)

View File

@@ -0,0 +1,62 @@
package ru.stenfredd.movies.playback.api
import android.widget.FrameLayout
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
* A playback engine (ExoPlayer, libVLC, ...). Every engine lives in its own Gradle module and is
* registered through an [EngineProvider]. The rest of the app only talks to this interface.
*
* All methods must be called on the main thread.
*/
interface PlaybackEngine {
val id: EngineId
val state: StateFlow<EngineState>
val events: Flow<EngineEvent>
val stats: StateFlow<EngineStats>
val positionMs: Long
val durationMs: Long
/** False once the engine knows it can't seek in this source (e.g. a Matroska index media3 can't find). */
val isSeekable: Boolean get() = true
/** Adds the engine's own video views (surface, subtitle overlay) into [container]. */
fun attachVideo(container: FrameLayout)
fun detachVideo()
fun load(request: LoadRequest)
fun play()
fun pause()
fun seekTo(positionMs: Long)
/** Selects tracks by Jellyfin `MediaStream.index`; `null` subtitle means "off". */
fun selectAudio(streamIndex: Int)
fun selectSubtitle(streamIndex: Int?)
fun release()
}
/** Optional capabilities: checked with `engine as? X`, the UI hides what an engine can't do. */
interface AudioDelayCapable {
fun setAudioDelay(delayMs: Long)
}
/**
* Engines that can show video either on the TV's hardware video plane (where the TV applies its own picture
* processing) or composited through the GPU like ordinary graphics (no TV processing, but no HDR and limited to
* the UI resolution).
*/
interface VideoPathSwitchable {
enum class Mode { AUTO, VIDEO_PLANE, GPU }
val videoPathMode: Mode
/** The path in use for the current file ([Mode.AUTO] resolved). */
val usingGpuPath: Boolean
fun setVideoPathMode(mode: Mode)
}
interface SubtitleDelayCapable {
fun setSubtitleDelay(delayMs: Long)
}