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)
}

View File

@@ -0,0 +1,23 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "ru.stenfredd.movies.playback.core"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
api(project(":playback:api"))
api(project(":core:jellyfin"))
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
}

View File

@@ -0,0 +1,93 @@
package ru.stenfredd.movies.playback.core
import android.content.Context
import android.hardware.display.DisplayManager
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.os.Build
import android.view.Display
import dagger.hilt.android.qualifiers.ApplicationContext
import ru.stenfredd.movies.playback.api.DecoderInfo
import ru.stenfredd.movies.playback.api.DeviceCapabilities
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DeviceCapabilitiesProbe @Inject constructor(
@ApplicationContext private val context: Context,
) {
val capabilities: DeviceCapabilities by lazy { probe().also { Timber.i("Device capabilities: %s", it) } }
private fun probe(): DeviceCapabilities {
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder }
val video = mutableListOf<DecoderInfo>()
val audio = mutableListOf<DecoderInfo>()
for (info in codecs) for (type in info.supportedTypes) {
val caps = runCatching { info.getCapabilitiesForType(type) }.getOrNull() ?: continue
val vc = caps.videoCapabilities
val decoder = DecoderInfo(
name = info.name,
mime = type,
hardware = isHardware(info),
maxWidth = vc?.supportedWidths?.upper ?: 0,
maxHeight = vc?.supportedHeights?.upper ?: 0,
profiles = caps.profileLevels.map { it.profile }.toSet(),
)
if (type.startsWith("video/")) video += decoder else if (type.startsWith("audio/")) audio += decoder
}
val display = context.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY)
val hdrTypes = if (Build.VERSION.SDK_INT >= 34) {
display.mode.supportedHdrTypes.toSet()
} else {
@Suppress("DEPRECATION")
display.hdrCapabilities?.supportedHdrTypes?.toSet().orEmpty()
}
return DeviceCapabilities(
manufacturer = Build.MANUFACTURER,
model = Build.MODEL,
soc = if (Build.VERSION.SDK_INT >= 31) "${Build.SOC_MANUFACTURER} ${Build.SOC_MODEL}" else Build.HARDWARE,
sdkInt = Build.VERSION.SDK_INT,
abis = Build.SUPPORTED_ABIS.toList(),
videoDecoders = video,
audioDecoders = audio,
displayHdrTypes = hdrTypes,
displayModes = display.supportedModes.map { "${it.physicalWidth}x${it.physicalHeight}@${"%.3f".format(it.refreshRate)}" },
passthroughEncodings = probePassthrough(),
)
}
private fun isHardware(info: MediaCodecInfo): Boolean = if (Build.VERSION.SDK_INT >= 29) {
info.isHardwareAccelerated
} else {
val n = info.name.lowercase()
!(n.startsWith("omx.google.") || n.startsWith("c2.android.") || n.contains("sw"))
}
private fun probePassthrough(): Set<Int> {
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
val candidates = listOf(
AudioFormat.ENCODING_AC3, AudioFormat.ENCODING_E_AC3, AudioFormat.ENCODING_E_AC3_JOC,
AudioFormat.ENCODING_DOLBY_TRUEHD, AudioFormat.ENCODING_DTS, AudioFormat.ENCODING_DTS_HD,
AudioFormat.ENCODING_AC4, AudioFormat.ENCODING_DOLBY_MAT,
)
return candidates.filter { encoding ->
listOf(AudioFormat.CHANNEL_OUT_7POINT1_SURROUND, AudioFormat.CHANNEL_OUT_5POINT1, AudioFormat.CHANNEL_OUT_STEREO)
.any { mask ->
runCatching {
@Suppress("DEPRECATION")
AudioTrack.isDirectPlaybackSupported(
AudioFormat.Builder().setEncoding(encoding).setSampleRate(48_000).setChannelMask(mask).build(),
attributes,
)
}.getOrDefault(false)
}
}.toSet()
}
}

View File

@@ -0,0 +1,41 @@
package ru.stenfredd.movies.playback.core
import org.jellyfin.sdk.model.api.MediaSourceInfo
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.StreamSelection
import ru.stenfredd.movies.playback.api.Verdict
import javax.inject.Inject
data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
/**
* Orders engines for a given file: direct play first (by priority), then degraded ones by the weight
* of what is lost (HDR loss is the worst), then engines that say "no" — those can still play a
* server transcode as the last resort.
*/
class EngineSelector @Inject constructor(
private val providers: Set<@JvmSuppressWildcards EngineProvider>,
) {
val engines: List<EngineProvider> get() = providers.sortedBy { it.priority }
fun provider(id: EngineId) = providers.first { it.id == id }
fun rank(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): List<RankedEngine> =
providers.map { RankedEngine(it, it.evaluate(source, selection, device)) }
.sortedWith(compareBy<RankedEngine> { score(it.verdict) }.thenBy { it.provider.priority })
private fun score(verdict: Verdict): Int = when (verdict) {
is Verdict.Direct -> 0
is Verdict.Degraded -> verdict.losses.sumOf(Loss::weight)
is Verdict.No -> 10_000
}
}
fun Verdict.describe(): String = when (this) {
is Verdict.Direct -> "напрямую" + notes.joinToString(prefix = if (notes.isEmpty()) "" else ": ")
is Verdict.Degraded -> "с потерями: " + (losses.map { it.label } + notes).joinToString()
is Verdict.No -> "не может: " + reasons.joinToString()
}

View File

@@ -0,0 +1,472 @@
package ru.stenfredd.movies.playback.core
import android.widget.FrameLayout
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.HttpMethod
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.client.extensions.videoApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
import ru.stenfredd.movies.playback.api.EngineError
import ru.stenfredd.movies.playback.api.EngineEvent
import ru.stenfredd.movies.playback.api.EngineId
import ru.stenfredd.movies.playback.api.EngineProvider
import ru.stenfredd.movies.playback.api.EngineState
import ru.stenfredd.movies.playback.api.EngineStats
import ru.stenfredd.movies.playback.api.ExternalSubtitle
import ru.stenfredd.movies.playback.api.LoadRequest
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.videoStream
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
data class PlaybackUiState(
val title: String = "",
val loading: Boolean = true,
val engine: EngineId? = null,
val engineReason: String = "",
val engineState: EngineState = EngineState(),
val positionMs: Long = 0,
val durationMs: Long = 0,
val playMethod: PlayMethod? = null,
val transcodeReasons: String? = null,
val source: MediaSourceInfo? = null,
val selection: StreamSelection = StreamSelection(null, null),
val ranking: List<Pair<EngineId, Verdict>> = emptyList(),
val stats: EngineStats = EngineStats(),
val history: List<String> = emptyList(),
val error: String? = null,
)
class PlaybackSessionFactory @Inject constructor(
private val session: JellyfinSession,
private val selector: EngineSelector,
private val probe: DeviceCapabilitiesProbe,
) {
fun create(scope: CoroutineScope) = PlaybackSession(session, selector, probe, scope)
}
/**
* One playback of one item: picks the engine, asks the server for a stream with that engine's
* profile, reports progress, and on an engine failure switches to the next engine at the same
* position (falling back to a server transcode as the last resort).
*
* Must be driven from the main thread.
*/
class PlaybackSession(
private val session: JellyfinSession,
private val selector: EngineSelector,
private val probe: DeviceCapabilitiesProbe,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow(PlaybackUiState())
val state: StateFlow<PlaybackUiState> = _state.asStateFlow()
private val api get() = session.api
private val device get() = probe.capabilities
private var engine: PlaybackEngine? = null
/** For optional engine capabilities (`as? X`) in the UI. */
val activeEngine: PlaybackEngine? get() = engine
private var container: FrameLayout? = null
private var generation = 0
private var engineJobs = mutableListOf<Job>()
private lateinit var item: BaseItemDto
private lateinit var source: MediaSourceInfo
private var selection = StreamSelection(null, null)
private var ranking: List<RankedEngine> = emptyList()
private val tried = mutableSetOf<EngineId>()
private var transcodeTried = false
private var playSessionId: String? = null
private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
private var playSource: MediaSourceInfo? = null
private var reportedStart = false
fun attachVideo(view: FrameLayout) {
container = view
engine?.attachVideo(view)
}
fun detachVideo() {
engine?.detachVideo()
container = null
}
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long) {
scope.launch {
runCatching {
val user = session.requireSession()
withContext(Dispatchers.Default) { probe.capabilities }
item = session.call { libraryApi.getItem(itemId, user.userId).content }
source = item.mediaSources?.firstOrNull() ?: error("У элемента нет медиаисточников")
selection = StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex)
ranking = selector.rank(source, selection, device)
_state.update {
it.copy(
title = item.name.orEmpty(),
source = source,
selection = selection,
ranking = ranking.map { r -> r.provider.id to r.verdict },
)
}
log("Ранжирование: " + ranking.joinToString { "${it.provider.id}=${it.verdict.describe()}" })
val first = forcedEngine?.let(selector::provider) ?: ranking.first().provider
val reason = if (forcedEngine != null) "выбран вручную" else ranking.first().verdict.describe()
launchEngine(first, startPositionMs, forceTranscode = false, reason = reason)
}.onFailure { fail("Не удалось начать воспроизведение", it) }
}
}
fun togglePlayPause() {
val e = engine ?: return
if (e.state.value.playWhenReady) e.pause() else e.play()
reportProgressAsync()
}
fun seekBy(deltaMs: Long) = seekTo((engine?.positionMs ?: 0) + deltaMs)
fun seekTo(positionMs: Long) {
val e = engine ?: return
val duration = e.durationMs.takeIf { it > 0 } ?: Long.MAX_VALUE
val target = positionMs.coerceIn(0, duration)
if (!e.isSeekable) {
// Hand over to an engine that can seek in this file, already at the target position.
val next = ranking.firstOrNull { it.provider.id != e.id && it.verdict !is Verdict.No }?.provider
if (next != null) {
scope.launch {
runCatching { launchEngine(next, target, forceTranscode = false, reason = "${e.id} не умеет перематывать этот файл") }
.onFailure { fail("Не удалось переключиться", it) }
}
return
}
}
e.seekTo(target)
reportProgressAsync()
}
fun selectAudio(streamIndex: Int) {
selection = selection.copy(audioStreamIndex = streamIndex)
_state.update { it.copy(selection = selection) }
if (playMethod == PlayMethod.DIRECT_PLAY) engine?.selectAudio(streamIndex) else restartCurrent("смена аудио")
}
fun selectSubtitle(streamIndex: Int?) {
selection = selection.copy(subtitleStreamIndex = streamIndex)
_state.update { it.copy(selection = selection) }
if (playMethod == PlayMethod.DIRECT_PLAY) engine?.selectSubtitle(streamIndex) else restartCurrent("смена субтитров")
}
fun switchEngine(id: EngineId) {
val position = engine?.positionMs ?: 0
scope.launch {
tried.clear()
transcodeTried = false
launchEngine(selector.provider(id), position, forceTranscode = false, reason = "выбран вручную")
}
}
fun forceTranscode() {
val position = engine?.positionMs ?: 0
val provider = engine?.id?.let(selector::provider) ?: return
scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") }
}
fun release() {
generation++
val old = engine
engine = null
engineJobs.forEach(Job::cancel)
old?.let { reportStopAsync(it.positionMs, failed = false) }
old?.release()
}
private fun restartCurrent(why: String) {
val provider = engine?.id?.let(selector::provider) ?: return
val position = engine?.positionMs ?: 0
scope.launch { launchEngine(provider, position, forceTranscode = playMethod != PlayMethod.DIRECT_PLAY, reason = why) }
}
private suspend fun launchEngine(provider: EngineProvider, startMs: Long, forceTranscode: Boolean, reason: String) {
val gen = ++generation
engineJobs.forEach(Job::cancel)
engineJobs.clear()
engine?.let { old ->
reportStop(old.positionMs, failed = false)
old.release()
}
engine = null
tried += provider.id
if (forceTranscode) transcodeTried = true
_state.update { it.copy(loading = true, error = null, engine = provider.id, engineReason = reason) }
log("Запуск ${provider.id} с ${startMs / 1000} с ($reason)" + if (forceTranscode) ", принудительный транскод" else "")
val user = session.requireSession()
val profile = provider.deviceProfile(device)
val info = session.call {
mediaInfoApi.getPostedPlaybackInfo(
item.id,
PlaybackInfoDto(
userId = user.userId,
maxStreamingBitrate = MAX_BITRATE,
startTimeTicks = startMs * TICKS_PER_MS,
audioStreamIndex = selection.audioStreamIndex,
subtitleStreamIndex = selection.subtitleStreamIndex,
mediaSourceId = source.id,
deviceProfile = profile,
enableDirectPlay = !forceTranscode,
enableDirectStream = !forceTranscode,
enableTranscoding = true,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
autoOpenLiveStream = true,
alwaysBurnInSubtitleWhenTranscoding = false,
),
).content
}
if (gen != generation) return
info.errorCode?.let { error("Сервер отказал: $it") }
val ms = info.mediaSources.firstOrNull() ?: error("PlaybackInfo без медиаисточника")
playSessionId = info.playSessionId
playSource = ms
val url: String
if (ms.supportsDirectPlay && !forceTranscode) {
playMethod = PlayMethod.DIRECT_PLAY
url = session.authorizedUrl(
api.videoApi.getVideoStreamUrl(
itemId = item.id,
static = true,
mediaSourceId = ms.id,
playSessionId = playSessionId,
deviceId = session.deviceId,
tag = ms.eTag,
),
)
} else {
val transcodingUrl = ms.transcodingUrl ?: error("Сервер не предложил ни прямого воспроизведения, ни транскода")
playMethod = PlayMethod.TRANSCODE
url = session.authorizedUrl(transcodingUrl)
}
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
log("${provider.id}: $playMethod" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: ""))
val external = ms.mediaStreams.orEmpty()
.filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL && it.deliveryUrl != null }
.map { ExternalSubtitle(it.index, session.authorizedUrl(it.deliveryUrl!!), it.codec, it.language, it.displayTitle) }
val newEngine = provider.create(device)
engine = newEngine
firstFrameSeen = false
container?.let(newEngine::attachVideo)
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } }
engineJobs += scope.launch { newEngine.state.collect { s -> _state.update { it.copy(engineState = s, loading = s.phase == EngineState.Phase.BUFFERING) } } }
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
engineJobs += scope.launch { tickLoop(gen) }
engineJobs += scope.launch { firstFrameWatchdog(gen) }
_state.update { it.copy(playMethod = playMethod, transcodeReasons = transcodeReasons) }
newEngine.load(
LoadRequest(
url = url,
playMethod = playMethod,
mediaSource = ms,
startPositionMs = startMs,
audioStreamIndex = selection.audioStreamIndex,
subtitleStreamIndex = selection.subtitleStreamIndex,
externalSubtitles = external,
),
)
newEngine.play()
reportStart(startMs)
}
private var firstFrameSeen = false
private suspend fun firstFrameWatchdog(gen: Int) {
if (source.videoStream == null) return
delay(FIRST_FRAME_TIMEOUT_MS)
if (gen == generation && !firstFrameSeen) {
onEngineEvent(gen, EngineEvent.Error(EngineError(EngineError.Kind.NO_VIDEO_OUTPUT, "Нет первого кадра за ${FIRST_FRAME_TIMEOUT_MS / 1000} с")))
}
}
private fun onEngineEvent(gen: Int, event: EngineEvent) {
if (gen != generation) return
when (event) {
EngineEvent.FirstFrameRendered -> {
firstFrameSeen = true
_state.update { it.copy(loading = false) }
}
is EngineEvent.Error -> {
val failedEngine = engine?.id
val position = engine?.positionMs ?: 0
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
val next = if (event.error.worthFallback) {
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
} else {
null
}
scope.launch {
runCatching {
when {
next != null -> launchEngine(next, position, forceTranscode = false, reason = "fallback после ошибки $failedEngine")
!transcodeTried -> launchEngine(ranking.first().provider, position, forceTranscode = true, reason = "все движки не смогли — транскод")
else -> fail("Воспроизведение не удалось: ${event.error.message}", event.error.cause)
}
}.onFailure { fail("Не удалось переключиться", it) }
}
}
}
}
private suspend fun tickLoop(gen: Int) {
var sinceReport = 0L
while (scope.isActive && gen == generation) {
val e = engine ?: return
_state.update { it.copy(positionMs = e.positionMs, durationMs = e.durationMs) }
delay(TICK_MS)
sinceReport += TICK_MS
if (sinceReport >= PROGRESS_INTERVAL_MS) {
sinceReport = 0
reportProgress()
}
}
}
private suspend fun reportStart(positionMs: Long) {
reportedStart = true
runCatching {
session.call { sessionApi.reportPlaybackStart(
PlaybackStartInfo(
canSeek = true,
itemId = item.id,
mediaSourceId = source.id,
audioStreamIndex = selection.audioStreamIndex,
subtitleStreamIndex = selection.subtitleStreamIndex,
isPaused = false,
isMuted = false,
positionTicks = positionMs * TICKS_PER_MS,
playMethod = playMethod,
playSessionId = playSessionId,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
),
) }
}.onFailure { Timber.w(it, "reportPlaybackStart failed") }
}
private suspend fun reportProgress() {
val e = engine ?: return
// Engines are main-thread only: read their state before switching to the network thread.
val paused = !e.state.value.playWhenReady
val positionTicks = e.positionMs * TICKS_PER_MS
runCatching {
session.call { sessionApi.reportPlaybackProgress(
PlaybackProgressInfo(
canSeek = true,
itemId = item.id,
mediaSourceId = source.id,
audioStreamIndex = selection.audioStreamIndex,
subtitleStreamIndex = selection.subtitleStreamIndex,
isPaused = paused,
isMuted = false,
positionTicks = positionTicks,
playMethod = playMethod,
playSessionId = playSessionId,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
),
) }
}.onFailure { Timber.w(it, "reportPlaybackProgress failed") }
}
private fun reportProgressAsync() {
scope.launch { reportProgress() }
}
private suspend fun reportStop(positionMs: Long, failed: Boolean) {
if (!reportedStart) return
reportedStart = false
val sessionId = playSessionId
val method = playMethod
withContext(NonCancellable) {
runCatching {
session.call { sessionApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = item.id,
mediaSourceId = source.id,
positionTicks = positionMs * TICKS_PER_MS,
playSessionId = sessionId,
failed = failed,
),
) }
if (method == PlayMethod.TRANSCODE && sessionId != null) {
// HLS endpoints are hidden from the 12.x OpenAPI, so the SDK has no method for this.
session.call {
request(
method = HttpMethod.DELETE,
pathTemplate = "/Videos/ActiveEncodings",
queryParameters = mapOf("deviceId" to session.deviceId, "playSessionId" to sessionId),
)
}
}
}.onFailure { Timber.w(it, "reportPlaybackStopped failed") }
}
}
private fun reportStopAsync(positionMs: Long, failed: Boolean) {
// The owner's scope is being cancelled at this point; reporting must still reach the server.
@Suppress("OPT_IN_USAGE")
kotlinx.coroutines.GlobalScope.launch { reportStop(positionMs, failed) }
}
private fun log(message: String) {
Timber.i(message)
_state.update { it.copy(history = (it.history + message).takeLast(20)) }
}
private fun fail(message: String, cause: Throwable?) {
Timber.e(cause, message)
log("$message: ${cause?.message.orEmpty()}")
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
}
private companion object {
const val TICKS_PER_MS = 10_000L
const val MAX_BITRATE = 400_000_000
const val TICK_MS = 500L
const val PROGRESS_INTERVAL_MS = 10_000L
const val FIRST_FRAME_TIMEOUT_MS = 60_000L
}
}

View 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)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "ru.stenfredd.movies.playback.vlc"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(project(":playback:api"))
implementation(libs.libvlc)
implementation(libs.okhttp)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
}

View File

@@ -0,0 +1,348 @@
package ru.stenfredd.movies.playback.vlc
import android.os.SystemClock
import okhttp3.Call
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import timber.log.Timber
import java.io.BufferedInputStream
import java.io.Closeable
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/**
* Minimal HTTP/1.1 proxy on 127.0.0.1 that sits between libVLC and the server.
*
* libVLC's own HTTPS stack (GnuTLS) misbehaves behind reverse proxies: after the Range request to the
* end of an MKV it reports "connection failed", every such seek stalls for ~10 s and the prefetch thread
* sometimes spins forever. Here libVLC only ever talks plain HTTP to loopback, while the real request
* goes through OkHttp (platform TLS with system/user CAs, connection pooling).
*
* Every response is sent with `Connection: close`, one request per socket. VLC opens a new connection on
* each seek and closing that socket cancels the upstream call.
*
* The access token never appears in the loopback URL: `ApiKey` is stripped from the query and sent
* upstream as an `Authorization` header instead. Loopback URLs carry a random route key so other apps on
* the device can't use the proxy. The upstream path is preserved after the key, so relative URLs (HLS
* playlists) resolve back through the proxy.
*
* Recently fetched bytes are kept in a [RecentBytesCache], see there why.
*/
internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
private val server = ServerSocket(0, BACKLOG, InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)))
private val routes = ConcurrentHashMap<String, Route>()
private val connections = ConcurrentHashMap.newKeySet<Connection>()
private val requestCounter = AtomicInteger()
private val bytes = AtomicLong()
private val cache = RecentBytesCache(CACHE_CAPACITY)
@Volatile
private var closed = false
/** Bytes sent to libVLC since the proxy started. */
val bytesTransferred: Long get() = bytes.get()
val activeConnections: Int get() = connections.size
init {
Thread(::acceptLoop, "vlc-proxy-accept").apply { isDaemon = true }.start()
}
private class Route(val origin: HttpUrl, val token: String?)
/** Loopback URL for [upstream]; non-HTTP URLs are returned unchanged. */
fun localUrl(upstream: String): String {
val url = upstream.toHttpUrlOrNull() ?: return upstream
val tokenParams = url.queryParameterNames.filter { name -> TOKEN_PARAMS.any { it.equals(name, ignoreCase = true) } }
val token = tokenParams.firstNotNullOfOrNull(url::queryParameter)
val stripped = url.newBuilder().apply { tokenParams.forEach(::removeAllQueryParameters) }.build()
val origin = HttpUrl.Builder().scheme(url.scheme).host(url.host).port(url.port).build()
val key = synchronized(routes) {
routes.entries.firstOrNull { it.value.origin == origin && it.value.token == token }?.key
?: newKey().also { routes[it] = Route(origin, token) }
}
val query = stripped.encodedQuery?.let { "?$it" }.orEmpty()
return "http://127.0.0.1:${server.localPort}/$key${stripped.encodedPath}$query"
}
override fun close() {
if (closed) return
closed = true
runCatching { server.close() }
connections.forEach(Connection::abort)
routes.clear()
}
private fun acceptLoop() {
while (!closed) {
val socket = try {
server.accept()
} catch (e: IOException) {
if (!closed) Timber.w(e, "VLC proxy: accept failed")
break
}
val connection = Connection(socket, requestCounter.incrementAndGet())
connections += connection
if (closed) {
connection.abort()
break
}
Thread(connection, "vlc-proxy-${connection.id}").apply { isDaemon = true }.start()
}
}
private inner class Connection(private val socket: Socket, val id: Int) : Runnable {
@Volatile
private var call: Call? = null
/** What this connection did, for the log line written when it ends. */
private var summary = "?"
fun abort() {
call?.cancel()
runCatching { socket.close() }
}
override fun run() {
val started = SystemClock.elapsedRealtime()
try {
socket.tcpNoDelay = true
socket.soTimeout = HEADER_TIMEOUT_MS
serve(BufferedInputStream(socket.getInputStream()), socket.getOutputStream())
} catch (e: IOException) {
// Normal when VLC drops the connection on seek or the engine is released.
summary += " — прервано: ${e.message}"
} finally {
call?.cancel()
runCatching { socket.close() }
connections -= this
Timber.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started)
}
}
private fun serve(input: InputStream, output: OutputStream) {
val requestLine = input.readHttpLine() ?: return
val headers = mutableMapOf<String, String>()
while (true) {
val line = input.readHttpLine() ?: break
if (line.isEmpty()) break
val colon = line.indexOf(':')
if (colon > 0) headers[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim()
}
val parts = requestLine.split(' ')
val method = parts.getOrNull(0).orEmpty()
val target = parts.getOrNull(1).orEmpty()
val range = headers["range"]
summary = "$method ${range ?: "весь файл"}"
if (method != "GET" && method != "HEAD") {
summary += " → 405"
output.writeHead(405, "Method Not Allowed", NO_BODY)
return
}
val upstream = upstreamUrl(target)
if (upstream == null) {
summary += " → 404"
output.writeHead(404, "Not Found", NO_BODY)
return
}
val cacheKey = upstream.url.toString()
val rangeStart = range?.let(OPEN_RANGE::matchEntire)?.groupValues?.get(1)?.toLong()
val meta = cache.meta(cacheKey)
if (method == "GET" && rangeStart != null && meta != null && rangeStart < meta.totalLength) {
serveThroughCache(output, upstream, headers, cacheKey, rangeStart, meta)
return
}
val response = execute(upstream, method, range, headers)
if (response == null) {
output.writeHead(502, "Bad Gateway", NO_BODY)
return
}
response.use {
val body = response.body
val passHeaders = PASS_HEADERS.mapNotNull { name -> response.header(name)?.let { name to it } }.toMutableList()
if (passHeaders.none { it.first == "Content-Length" } && body.contentLength() >= 0 && method == "GET") {
passHeaders += "Content-Length" to body.contentLength().toString()
}
summary += " → ${response.code} ${response.header("Content-Range").orEmpty()}"
output.writeHead(response.code, response.message.ifEmpty { "Status" }, passHeaders)
if (method == "HEAD") return
// Only byte-addressable responses can be cached.
val contentRange = response.header("Content-Range")?.let(CONTENT_RANGE::matchEntire)
val (offset, total) = when {
response.code == 206 && contentRange != null ->
contentRange.groupValues[1].toLong() to contentRange.groupValues[2].toLong()
response.code == 200 && body.contentLength() >= 0 -> 0L to body.contentLength()
else -> null to null
}
if (offset != null && total != null) {
cache.putMeta(cacheKey, RecentBytesCache.Meta(total, passHeaders.filter { it.first !in RANGE_HEADERS }))
}
pump(body.byteStream(), output, cacheKey, offset)
}
}
/** Answers `Range: bytes=start-` from the cache, then fetches whatever is left from the server. */
private fun serveThroughCache(
output: OutputStream,
upstream: Upstream,
headers: Map<String, String>,
cacheKey: String,
start: Long,
meta: RecentBytesCache.Meta,
) {
val total = meta.totalLength
summary += " → 206 через кэш"
output.writeHead(
206,
"Partial Content",
meta.headers + listOf("Content-Range" to "bytes $start-${total - 1}/$total", "Content-Length" to (total - start).toString()),
)
socket.soTimeout = 0
val cached = cache.writeFrom(cacheKey, start, output)
bytes.addAndGet(cached)
summary += ", из кэша ${cached / 1024} КБ"
val position = start + cached
if (position >= total) {
output.flush()
return
}
// The head is already sent: on any upstream problem just drop the connection, VLC reconnects.
val response = execute(upstream, "GET", "bytes=$position-", headers) ?: throw IOException("сервер недоступен")
response.use {
if (response.code != 206 || response.header("Content-Range")?.startsWith("bytes $position-") != true) {
throw IOException("сервер ответил ${response.code} ${response.header("Content-Range").orEmpty()}")
}
pump(response.body.byteStream(), output, cacheKey, position)
}
}
private fun execute(upstream: Upstream, method: String, range: String?, headers: Map<String, String>): Response? {
val request = Request.Builder().url(upstream.url).apply {
if (method == "HEAD") head()
range?.let { header("Range", it) }
headers["user-agent"]?.let { header("User-Agent", it) }
// Without this OkHttp transparently gunzips and drops Content-Length, which breaks seeking.
header("Accept-Encoding", "identity")
upstream.token?.let { header("Authorization", "MediaBrowser Token=\"$it\"") }
}.build()
val call = client.newCall(request).also { call = it }
if (closed) call.cancel()
return try {
call.execute()
} catch (e: IOException) {
summary += " → ошибка сервера: ${e.message}"
null
}
}
/** Copies the body to VLC; when [offset] is known, also keeps the bytes in [cache]. */
private fun pump(source: InputStream, output: OutputStream, cacheKey: String, offset: Long?) {
// Body reads are bounded by OkHttp's read timeout, not by the header timeout.
socket.soTimeout = 0
val buffer = ByteArray(COPY_BUFFER)
val pending = ByteArray(CACHE_BLOCK)
var pendingLength = 0
var pendingOffset = offset ?: 0
fun flushPending() {
if (offset != null && pendingLength > 0) cache.put(cacheKey, pendingOffset, pending.copyOf(pendingLength))
pendingOffset += pendingLength
pendingLength = 0
}
var sent = 0L
try {
while (!closed) {
val n = source.read(buffer)
if (n < 0) break
// Cache first: bytes VLC no longer wants (it hung up mid-write) are still valid.
if (offset != null) {
var i = 0
while (i < n) {
val chunk = minOf(n - i, CACHE_BLOCK - pendingLength)
System.arraycopy(buffer, i, pending, pendingLength, chunk)
pendingLength += chunk
i += chunk
if (pendingLength == CACHE_BLOCK) flushPending()
}
}
output.write(buffer, 0, n)
sent += n
bytes.addAndGet(n.toLong())
}
output.flush()
} finally {
flushPending()
summary += ", с сервера ${sent / 1024} КБ"
}
}
}
private class Upstream(val url: HttpUrl, val token: String?)
private fun upstreamUrl(target: String): Upstream? {
if (!target.startsWith("/")) return null
val keyEnd = target.indexOfAny(charArrayOf('/', '?'), startIndex = 1).let { if (it < 0) target.length else it }
val route = routes[target.substring(1, keyEnd)] ?: return null
val rest = target.substring(keyEnd)
val queryStart = rest.indexOf('?')
val path = (if (queryStart < 0) rest else rest.substring(0, queryStart)).ifEmpty { "/" }
val query = if (queryStart < 0) null else rest.substring(queryStart + 1)
val url = runCatching { route.origin.newBuilder().encodedPath(path).encodedQuery(query).build() }.getOrNull() ?: return null
return Upstream(url, route.token)
}
private fun newKey(): String {
val raw = ByteArray(16).also(random::nextBytes)
return raw.joinToString("") { "%02x".format(it) }
}
private companion object {
const val BACKLOG = 16
const val HEADER_TIMEOUT_MS = 30_000
const val COPY_BUFFER = 64 * 1024
const val MAX_LINE = 16 * 1024
const val CACHE_BLOCK = 256 * 1024
const val CACHE_CAPACITY = 32L * 1024 * 1024
val OPEN_RANGE = Regex("""bytes=(\d+)-""")
val CONTENT_RANGE = Regex("""bytes (\d+)-\d+/(\d+)""")
val RANGE_HEADERS = setOf("Content-Length", "Content-Range")
val TOKEN_PARAMS = listOf("ApiKey", "api_key")
val PASS_HEADERS = listOf("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "Last-Modified", "ETag")
val NO_BODY = listOf("Content-Length" to "0")
val random = SecureRandom()
fun InputStream.readHttpLine(): String? {
val sb = StringBuilder()
while (true) {
val c = read()
if (c < 0) return if (sb.isEmpty()) null else sb.toString()
if (c == '\n'.code) return sb.removeSuffix("\r").toString()
if (sb.length >= MAX_LINE) throw IOException("HTTP line too long")
sb.append(c.toChar())
}
}
fun OutputStream.writeHead(code: Int, message: String, headers: List<Pair<String, String>>) {
val head = buildString {
append("HTTP/1.1 ").append(code).append(' ').append(message).append("\r\n")
for ((name, value) in headers) append(name).append(": ").append(value).append("\r\n")
append("Connection: close\r\n\r\n")
}
write(head.toByteArray(Charsets.ISO_8859_1))
flush()
}
}
}

View File

@@ -0,0 +1,81 @@
package ru.stenfredd.movies.playback.vlc
import java.io.OutputStream
import java.util.TreeMap
/**
* Bytes recently fetched from the server, per URL, bounded by [capacity] (oldest blocks evicted first).
*
* libVLC's prefetch filter drops data as soon as it is read and fills its 16 MiB buffer ahead, so any small
* backward seek (the AVI demuxer does them constantly, MKV re-reads its tail) becomes a new request that
* would download the same ~16 MiB again. With this cache such requests are answered from memory and only
* the part that was never fetched goes to the server.
*/
internal class RecentBytesCache(private val capacity: Long) {
/** What is needed to answer a Range request without asking the server. */
data class Meta(val totalLength: Long, val headers: List<Pair<String, String>>)
private class Entry {
var meta: Meta? = null
val blocks = TreeMap<Long, ByteArray>()
}
private val entries = HashMap<String, Entry>()
/** Insertion order for eviction. */
private val order = ArrayDeque<Pair<String, Long>>()
private var size = 0L
@Synchronized
fun meta(url: String): Meta? = entries[url]?.meta
@Synchronized
fun putMeta(url: String, meta: Meta) {
val entry = entries.getOrPut(url, ::Entry)
if (entry.meta?.totalLength != meta.totalLength) entry.blocks.clear()
entry.meta = meta
}
@Synchronized
fun put(url: String, offset: Long, data: ByteArray) {
val entry = entries[url] ?: return
val floor = entry.blocks.floorEntry(offset)
// Already covered (two connections fetched the same bytes).
if (floor != null && floor.key + floor.value.size >= offset + data.size) return
entry.blocks.put(offset, data)?.let { size -= it.size }
size += data.size
order.addLast(url to offset)
while (size > capacity) {
val (oldUrl, oldOffset) = order.removeFirstOrNull() ?: break
entries[oldUrl]?.blocks?.remove(oldOffset)?.let { size -= it.size }
}
}
/**
* Writes the cached bytes contiguous from [offset] to [output] (outside the lock, blocks are immutable)
* and returns how many were written.
*/
fun writeFrom(url: String, offset: Long, output: OutputStream): Long {
val run = synchronized(this) { contiguousRun(url, offset) }
var position = offset
for ((start, data) in run) {
val skip = (position - start).toInt()
output.write(data, skip, data.size - skip)
position += data.size - skip
}
return position - offset
}
private fun contiguousRun(url: String, offset: Long): List<Pair<Long, ByteArray>> {
val blocks = entries[url]?.blocks ?: return emptyList()
val run = mutableListOf<Pair<Long, ByteArray>>()
var position = offset
while (true) {
val block = blocks.floorEntry(position) ?: break
val end = block.key + block.value.size
if (end <= position) break
run += block.key to block.value
position = end
}
return run
}
}

View File

@@ -0,0 +1,306 @@
package ru.stenfredd.movies.playback.vlc
import android.content.Context
import android.os.SystemClock
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.net.toUri
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 okhttp3.OkHttpClient
import org.videolan.libvlc.Dialog
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout
import ru.stenfredd.movies.playback.api.AudioDelayCapable
import ru.stenfredd.movies.playback.api.Codecs
import ru.stenfredd.movies.playback.api.DeviceCapabilities
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.SubtitleDelayCapable
import ru.stenfredd.movies.playback.api.embeddedOrdinal
import ru.stenfredd.movies.playback.api.stream
import ru.stenfredd.movies.playback.api.streams
import ru.stenfredd.movies.playback.api.videoStream
import timber.log.Timber
/**
* libVLC engine. Owns its LibVLC instance so every playback starts clean and releases all native
* objects in [release] (leaks here were what killed libVLC in the official Android TV client).
* All network access goes through [LoopbackProxy]: libVLC itself only sees plain HTTP on 127.0.0.1.
*/
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) : PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable {
override val id = VlcEngineProvider.ID
private val libVlc = LibVLC(context.applicationContext, OPTIONS)
private val player = MediaPlayer(libVlc)
private val proxy = LoopbackProxy(httpClient)
private var videoLayout: VLCVideoLayout? = null
private var media: Media? = null
private var viewsAttached = false
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(videoDecoder = "libVLC ${LibVLC.version()}"))
override val stats = _stats.asStateFlow()
override val positionMs get() = player.time.coerceAtLeast(0)
override val durationMs get() = player.length.coerceAtLeast(0)
private var request: LoadRequest? = null
private var tracksApplied = false
private var errorReported = false
private var firstFrame = false
private var lastDialogError: String? = null
private var lastStatsAt = 0L
private var released = false
private var audioOutputMode: String? = null
private val dialogCallbacks = object : Dialog.Callbacks {
override fun onDisplay(dialog: Dialog.ErrorMessage) {
lastDialogError = "${dialog.title}: ${dialog.text}"
Timber.w("VLC error dialog: %s", lastDialogError)
_stats.update { it.copy(extra = it.extra + ("vlc" to lastDialogError.orEmpty())) }
}
override fun onDisplay(dialog: Dialog.LoginDialog) = dialog.dismiss()
override fun onDisplay(dialog: Dialog.QuestionDialog) {
// E.g. an untrusted TLS certificate. Never accept silently.
Timber.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
lastDialogError = "${dialog.title}: ${dialog.text}"
dialog.dismiss()
}
override fun onDisplay(dialog: Dialog.ProgressDialog) = Unit
override fun onCanceled(dialog: Dialog?) = Unit
override fun onProgressUpdate(dialog: Dialog.ProgressDialog) = Unit
}
init {
Dialog.setCallbacks(libVlc, dialogCallbacks)
player.setEventListener(::onEvent)
}
override fun attachVideo(container: FrameLayout) {
detachVideo()
// Must be created with the Activity context: libVLC's VideoHelper only computes the surface size
// when it can resolve an Activity, otherwise the video is shown at its native size in a corner.
val layout = VLCVideoLayout(container.context).apply {
// libVLC sizes its surfaces once; re-apply when the container gets its real size.
addOnLayoutChangeListener { _, l, t, r, b, ol, ot, or, ob ->
if (viewsAttached && (r - l != or - ol || b - t != ob - ot)) player.updateVideoSurfaces()
}
}
videoLayout = layout
container.addView(layout, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
player.attachViews(layout, null, true, false)
player.videoScale = MediaPlayer.ScaleType.SURFACE_BEST_FIT
viewsAttached = true
}
override fun detachVideo() {
if (viewsAttached) {
player.detachViews()
viewsAttached = false
}
videoLayout?.let { (it.parent as? ViewGroup)?.removeView(it) }
videoLayout = null
}
override fun load(request: LoadRequest) {
this.request = request
tracksApplied = false
val video = request.mediaSource.videoStream
// VLC is the software fallback: hardware-friendly streams go to ExoPlayer. Probing MediaCodec from libVLC
// also takes ~45 s on MediaTek TVs (every getCapabilitiesForType call throws), so only use it where
// software decoding cannot keep up.
val needsHardware = (video?.width ?: 0) * (video?.height ?: 0) > 1920 * 1088
val m = Media(libVlc, proxy.localUrl(request.url).toUri()).apply {
setHWDecoderEnabled(needsHardware, false)
if (request.startPositionMs > 0) addOption(":start-time=${request.startPositionMs / 1000.0}")
for (sub in request.externalSubtitles) addSlave(IMedia.Slave(IMedia.Slave.Type.Subtitle, 4, proxy.localUrl(sub.url)))
}
media?.release()
media = m
player.media = m
applyAudioOutput(request.audioStreamIndex)
_state.value = EngineState(EngineState.Phase.BUFFERING, false)
}
override fun play() {
player.play()
_state.update { it.copy(playWhenReady = true) }
}
override fun pause() {
player.pause()
_state.update { it.copy(playWhenReady = false) }
}
override fun seekTo(positionMs: Long) {
player.setTime(positionMs, false)
}
override fun selectAudio(streamIndex: Int) {
val req = request ?: return
val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.AUDIO) ?: return
player.audioTracks?.filter { it.id >= 0 }?.getOrNull(ordinal)?.let { player.setAudioTrack(it.id) }
applyAudioOutput(streamIndex)
}
/**
* libVLC's default "pcm" device outputs multichannel PCM as is. On the Xiaomi TV 5.1 PCM is routed to a
* DIRECT output that never plays (silence). So: formats the device takes undecoded go out as passthrough
* (like ExoPlayer, the TV's own Dolby/DTS decoder handles them), everything else is downmixed to stereo.
*/
private fun applyAudioOutput(streamIndex: Int?) {
val source = request?.mediaSource ?: return
val stream = source.stream(streamIndex)?.takeIf { it.type == MediaStreamType.AUDIO }
?: source.streams(MediaStreamType.AUDIO).let { all -> all.firstOrNull { it.isDefault } ?: all.firstOrNull() }
val encoding = Codecs.passthroughEncoding(stream?.codec, stream?.profile)
val passthrough = encoding != null && encoding in device.passthroughEncodings
val mode = if (passthrough) "encoded" else "stereo"
if (mode == audioOutputMode) return
audioOutputMode = mode
if (passthrough) {
player.forceAudioDigitalEncodings(device.passthroughEncodings.toIntArray())
} else {
player.setAudioOutputDevice(mode)
}
val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${stream?.codec ?: "?"})"
Timber.i("VLC audio output: %s", label)
_stats.update { it.copy(audioOutput = label) }
}
override fun selectSubtitle(streamIndex: Int?) {
val req = request ?: return
if (streamIndex == null) {
player.setSpuTrack(-1)
return
}
val tracks = player.spuTracks?.filter { it.id >= 0 } ?: return
val stream = req.mediaSource.mediaStreams.orEmpty().firstOrNull { it.index == streamIndex } ?: return
val track = if (stream.isExternal || req.externalSubtitles.any { it.streamIndex == streamIndex }) {
// Slaves are appended after the embedded tracks, in the order they were added.
val externalPosition = req.externalSubtitles.indexOfFirst { it.streamIndex == streamIndex }
val embeddedCount = req.mediaSource.mediaStreams.orEmpty().count { it.type == MediaStreamType.SUBTITLE && !it.isExternal }
tracks.getOrNull(embeddedCount + externalPosition)
} else {
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
}
track?.let { player.setSpuTrack(it.id) }
}
override fun setAudioDelay(delayMs: Long) {
player.setAudioDelay(delayMs * 1000)
}
override fun setSubtitleDelay(delayMs: Long) {
player.setSpuDelay(delayMs * 1000)
}
override fun release() {
if (released) return
released = true
player.setEventListener(null)
detachVideo()
val m = media
media = null
// stop() joins libVLC's input threads; a stuck stream can block it indefinitely, which must never
// freeze the UI. Native teardown therefore happens on its own thread.
Thread({
val started = SystemClock.elapsedRealtime()
// Closing the proxy first fails VLC's pending reads, so stop() doesn't wait on the network.
proxy.close()
player.stop()
Dialog.setCallbacks(libVlc, null)
m?.release()
player.release()
libVlc.release()
Timber.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started)
}, "vlc-release").start()
}
private fun onEvent(event: MediaPlayer.Event) {
if (released) return
when (event.type) {
MediaPlayer.Event.Buffering -> {
val phase = if (event.buffering < 100f) EngineState.Phase.BUFFERING else EngineState.Phase.READY
_state.update { it.copy(phase = phase) }
}
MediaPlayer.Event.Playing -> _state.update { it.copy(phase = EngineState.Phase.READY, playWhenReady = true) }
MediaPlayer.Event.Paused -> _state.update { it.copy(playWhenReady = false) }
MediaPlayer.Event.EndReached -> _state.update { it.copy(phase = EngineState.Phase.ENDED) }
MediaPlayer.Event.Vout -> if (event.voutCount > 0 && !firstFrame) {
firstFrame = true
_events.tryEmit(EngineEvent.FirstFrameRendered)
updateStats(force = true)
}
MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESSelected -> applyInitialTracks()
MediaPlayer.Event.TimeChanged -> updateStats(force = false)
MediaPlayer.Event.EncounteredError -> reportError(
EngineError(EngineError.Kind.DECODER, lastDialogError ?: "libVLC: EncounteredError"),
)
}
}
private fun applyInitialTracks() {
val req = request ?: return
if (tracksApplied || req.playMethod != PlayMethod.DIRECT_PLAY) return
if (player.audioTracksCount <= 0) return
tracksApplied = true
req.audioStreamIndex?.let(::selectAudio)
selectSubtitle(req.subtitleStreamIndex)
}
private fun updateStats(force: Boolean) {
val now = SystemClock.elapsedRealtime()
if (!force && now - lastStatsAt < 1000) return
lastStatsAt = now
val video = player.currentVideoTrack
val s = media?.stats
_stats.update {
it.copy(
videoFormat = video?.let { v ->
"${v.codec} ${v.width}x${v.height} " + if (v.frameRateDen > 0) "%.3ffps".format(v.frameRateNum.toDouble() / v.frameRateDen) else ""
},
droppedFrames = s?.lostPictures ?: it.droppedFrames,
extra = it.extra + listOfNotNull(
s?.let { st -> "кадры показано/декод." to "${st.displayedPictures}/${st.decodedVideo}" },
s?.let { st -> "битрейт входа" to "%.1f Мбит/с".format(st.inputBitrate * 8000 / 1000) },
"прокси" to "${proxy.activeConnections} соед., %.1f МБ".format(proxy.bytesTransferred / 1048576.0),
),
)
}
}
private fun reportError(error: EngineError) {
if (errorReported) return
errorReported = true
Timber.w("VLC error: %s", error.message)
_events.tryEmit(EngineEvent.Error(error))
}
private companion object {
val OPTIONS = arrayListOf(
"--network-caching=3000",
"--audio-time-stretch",
"--no-sub-autodetect-file",
"--deinterlace=-1",
"--deinterlace-mode=yadif",
"-vv",
)
}
}

View File

@@ -0,0 +1,16 @@
package ru.stenfredd.movies.playback.vlc
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 VlcEngineModule {
@Binds
@IntoSet
abstract fun bindProvider(provider: VlcEngineProvider): EngineProvider
}

View File

@@ -0,0 +1,96 @@
package ru.stenfredd.movies.playback.vlc
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
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.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.VideoRange
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.videoStream
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VlcEngineProvider @Inject constructor(
@ApplicationContext private val context: Context,
) : EngineProvider {
override val id = ID
override val displayName = "VLC"
override val priority = 10
/** Shared by every [VlcEngine]'s proxy so connections to the server are pooled across playbacks. */
private val httpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
override fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict {
val losses = mutableSetOf<Loss>()
val notes = mutableListOf<String>()
source.videoStream?.let { v ->
val mime = Codecs.videoMime(v.codec)
val hardware = mime != null && device.hasHardwareVideo(mime, v.width, v.height) &&
!(v.codec.equals("h264", true) && (v.bitDepth ?: 8) > 8)
// Hardware decoding is only used by this engine for >1080p (see VlcEngine), so treat the rest as software.
if (!hardware || (v.width ?: 0) * (v.height ?: 0) <= 1920 * 1088) {
val pixels = (v.width ?: 0) * (v.height ?: 0)
losses += if (pixels > 1920 * 1088 || (v.bitDepth ?: 8) > 8) Loss.SOFTWARE_DECODE_HEAVY else Loss.SOFTWARE_DECODE
notes += v.codec.orEmpty()
}
when {
v.videoRangeType.serialName.startsWith("DOVI") -> losses += Loss.DV_TO_BASE_LAYER
v.videoRange == VideoRange.HDR -> losses += if (hardware) Loss.HDR_UNVERIFIED else Loss.HDR_LOST
}
if (v.videoRangeType == VideoRangeType.DOVI) losses += Loss.HDR_LOST
}
return if (losses.isEmpty()) Verdict.Direct(notes) else Verdict.Degraded(losses, notes)
}
override fun deviceProfile(device: DeviceCapabilities): DeviceProfile = buildDeviceProfile {
name = "Movies / VLC"
maxStreamingBitrate = MAX_BITRATE
maxStaticBitrate = MAX_BITRATE
// libVLC demuxes and decodes practically anything: no container/codec restrictions.
directPlayProfile { type = DlnaProfileType.VIDEO }
directPlayProfile { type = DlnaProfileType.AUDIO }
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
protocol = MediaStreamProtocol.HLS
container = "ts"
videoCodec("h264", "hevc")
audioCodec("aac", "ac3", "eac3", "mp3")
minSegments = 1
}
for (format in listOf("srt", "subrip", "ass", "ssa", "pgssub", "dvdsub", "dvbsub", "mov_text", "vtt", "webvtt", "sub", "sup", "idx")) {
subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
}
for (format in listOf("srt", "subrip", "ass", "ssa", "vtt", "webvtt", "sub", "sup")) {
subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
}
}
override fun create(device: DeviceCapabilities): PlaybackEngine = VlcEngine(context, httpClient, device)
companion object {
val ID = EngineId("vlc")
private const val MAX_BITRATE = 400_000_000
}
}