Playback core: ExoPlayer and libVLC engines
This commit is contained in:
23
playback/core/build.gradle.kts
Normal file
23
playback/core/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user