Playback core: ExoPlayer and libVLC engines
This commit is contained in:
18
playback/api/build.gradle.kts
Normal file
18
playback/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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, "без деинтерлейса в плеере"),
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user