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