Playback fixes: stall watchdog, DVD and MPEG-2, LAN streaming, Dolby Vision

This commit is contained in:
2026-10-14 09:24:35 +03:00
parent dec4b44571
commit 69a793abcb
28 changed files with 2788 additions and 83 deletions

View File

@@ -31,7 +31,11 @@ internal class RecentBytesCache(private val capacity: Long) {
@Synchronized
fun putMeta(url: String, meta: Meta) {
val entry = entries.getOrPut(url, ::Entry)
if (entry.meta?.totalLength != meta.totalLength) entry.blocks.clear()
if (entry.meta?.totalLength != meta.totalLength) {
size -= entry.blocks.values.sumOf { it.size.toLong() }
entry.blocks.clear()
order.removeAll { it.first == url }
}
entry.meta = meta
}
@@ -41,7 +45,10 @@ internal class RecentBytesCache(private val capacity: Long) {
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 }
entry.blocks.put(offset, data)?.let { old ->
size -= old.size
order.remove(url to offset)
}
size += data.size
order.addLast(url to offset)
while (size > capacity) {

View File

@@ -13,6 +13,7 @@ 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.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.videolan.libvlc.Dialog
import org.videolan.libvlc.LibVLC
@@ -130,6 +131,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
val m = Media(libVlc, proxy.localUrl(request.url).toUri()).apply {
setHWDecoderEnabled(needsHardware, false)
if (request.startPositionMs > 0) addOption(":start-time=${request.startPositionMs / 1000.0}")
if (request.startPositionMs > 0) startSeek(request.startPositionMs) else seekTargetMs = -1
for (sub in request.externalSubtitles) addSlave(IMedia.Slave(IMedia.Slave.Type.Subtitle, 4, proxy.localUrl(sub.url)))
}
media?.release()
@@ -150,9 +152,48 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
}
override fun seekTo(positionMs: Long) {
startSeek(positionMs)
player.setTime(positionMs, false)
}
/*
* libVLC seeks in MPEG-PS (DVD rips) by an average-bitrate estimate and lands minutes off: asked for 30:00 it
* played 26:25 of a DVD rip, and its clock is right about that (checked against ffmpeg frames),
* so the user simply sees the wrong scene. After a seek (or a start position) we look where playback landed and
* re-seek with the error subtracted, a few times at most. Precise containers land within tolerance at once.
*/
private var seekTargetMs = -1L
private var seekRequestMs = 0L
private var seekIssuedAt = 0L
private var seekAttempts = 0
private fun startSeek(targetMs: Long) {
seekTargetMs = targetMs
seekRequestMs = targetMs
seekIssuedAt = SystemClock.elapsedRealtime()
seekAttempts = 0
}
private fun checkSeek() {
if (seekTargetMs < 0) return
val time = player.time
val elapsed = SystemClock.elapsedRealtime() - seekIssuedAt
if (time <= 0 || elapsed < SEEK_SETTLE_MS) return
// Playback has been running for at most `elapsed` since the seek.
val landed = time - elapsed.coerceAtMost(time)
val error = seekTargetMs - landed
if (error in -SEEK_TOLERANCE_MS..SEEK_TOLERANCE_MS || seekAttempts >= MAX_SEEK_CORRECTIONS) {
if (seekAttempts > 0) Timber.i("VLC seek to %d s: landed at %d s after %d corrections", seekTargetMs / 1000, landed / 1000, seekAttempts)
seekTargetMs = -1
return
}
seekAttempts++
seekRequestMs = (seekRequestMs + error).coerceAtLeast(0)
seekIssuedAt = SystemClock.elapsedRealtime()
Timber.i("VLC seek to %d s landed at %d s, retrying with %d s", seekTargetMs / 1000, landed / 1000, seekRequestMs / 1000)
player.setTime(seekRequestMs, false)
}
override fun selectAudio(streamIndex: Int) {
val req = request ?: return
val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.AUDIO) ?: return
@@ -169,7 +210,8 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
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 codec = outputAudioCodec(stream?.codec)
val encoding = Codecs.passthroughEncoding(codec, stream?.profile.takeIf { codec == stream?.codec })
val passthrough = encoding != null && encoding in device.passthroughEncodings
val mode = if (passthrough) "encoded" else "stereo"
if (mode == audioOutputMode) return
@@ -179,18 +221,38 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
} else {
player.setAudioOutputDevice(mode)
}
val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${stream?.codec ?: "?"})"
val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${codec ?: "?"})"
Timber.i("VLC audio output: %s", label)
_stats.update { it.copy(audioOutput = label) }
}
/**
* The codec VLC will actually get. A transcode keeps the source audio only if it is in the URL's `AudioCodec`
* list (stream copy), otherwise the server encodes to the first one: TrueHD transcoded comes out as AAC, and
* forcing TrueHD passthrough for it would give silence.
*/
private fun outputAudioCodec(sourceCodec: String?): String? {
val req = request ?: return sourceCodec
if (req.playMethod != PlayMethod.TRANSCODE) return sourceCodec
val allowed = req.url.toHttpUrlOrNull()?.queryParameter("AudioCodec")?.split(',')?.filter(String::isNotBlank)
if (allowed.isNullOrEmpty()) return sourceCodec
return allowed.firstOrNull { it.equals(sourceCodec, ignoreCase = true) } ?: allowed.first()
}
/**
* Subtitle asked for before its track existed: external files are slaves that libVLC opens after the main
* input, so at the first ESAdded only the embedded tracks are there. Retried on every later ESAdded.
*/
private var pendingSubtitle: Int? = null
override fun selectSubtitle(streamIndex: Int?) {
val req = request ?: return
pendingSubtitle = null
if (streamIndex == null) {
player.setSpuTrack(-1)
return
}
val tracks = player.spuTracks?.filter { it.id >= 0 } ?: return
val tracks = player.spuTracks?.filter { it.id >= 0 }.orEmpty()
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.
@@ -200,7 +262,13 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
} else {
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
}
track?.let { player.setSpuTrack(it.id) }
if (track == null) {
Timber.d("VLC subtitle %d not there yet (spu tracks: %s)", streamIndex, tracks.joinToString { "${it.id} ${it.name}" })
pendingSubtitle = streamIndex
return
}
Timber.i("VLC subtitle %d -> track %d «%s»", streamIndex, track.id, track.name)
player.setSpuTrack(track.id)
}
override fun setAudioDelay(delayMs: Long) {
@@ -248,8 +316,14 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
_events.tryEmit(EngineEvent.FirstFrameRendered)
updateStats(force = true)
}
MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESSelected -> applyInitialTracks()
MediaPlayer.Event.TimeChanged -> updateStats(force = false)
MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESSelected -> {
applyInitialTracks()
pendingSubtitle?.let(::selectSubtitle)
}
MediaPlayer.Event.TimeChanged -> {
checkSeek()
updateStats(force = false)
}
MediaPlayer.Event.EncounteredError -> reportError(
EngineError(EngineError.Kind.DECODER, lastDialogError ?: "libVLC: EncounteredError"),
)
@@ -271,6 +345,16 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
lastStatsAt = now
val video = player.currentVideoTrack
val s = media?.stats
// libVLC's decodedVideo counts frames twice and inputBitrate averages in the prefetch: use deltas instead.
val rates = s?.let { st ->
val prev = lastStatsSample
lastStatsSample = StatsSample(now, st.displayedPictures, st.readBytes)
prev?.takeIf { now > it.atMs && st.displayedPictures >= it.displayed }?.let { p ->
val sec = (now - p.atMs) / 1000.0
// readBytes is an Int that wraps after 2 GB; the Int difference is still right.
(st.displayedPictures - p.displayed) / sec to (st.readBytes - p.readBytes).toUInt().toDouble() * 8 / sec / 1e6
}
}
_stats.update {
it.copy(
videoFormat = video?.let { v ->
@@ -278,14 +362,18 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
},
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) },
s?.let { st -> "кадров показано" to "${st.displayedPictures}" },
rates?.let { (fps, _) -> "кадров/с на экране" to "%.1f".format(fps) },
rates?.let { (_, mbps) -> "чтение входа" to "%.1f Мбит/с".format(mbps) },
"прокси" to "${proxy.activeConnections} соед., %.1f МБ".format(proxy.bytesTransferred / 1048576.0),
),
)
}
}
private class StatsSample(val atMs: Long, val displayed: Int, val readBytes: Int)
private var lastStatsSample: StatsSample? = null
private fun reportError(error: EngineError) {
if (errorReported) return
errorReported = true
@@ -294,13 +382,16 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
}
private companion object {
const val SEEK_SETTLE_MS = 1500L
const val SEEK_TOLERANCE_MS = 5000L
const val MAX_SEEK_CORRECTIONS = 3
val OPTIONS = arrayListOf(
"--network-caching=3000",
"--audio-time-stretch",
"--no-sub-autodetect-file",
"--deinterlace=-1",
"--deinterlace-mode=yadif",
"-vv",
"-v",
)
}
}

View File

@@ -51,7 +51,11 @@ class VlcEngineProvider @Inject constructor(
// 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
// Deinterlacing (yadif) doubles the frame rate. The tour on the TV: 720p60 H.264 and 1080i50 drop
// frames in VLC, 1080p at 24-30 and SD at 50 (deinterlaced DVD) play fine.
val fps = (v.realFrameRate ?: v.averageFrameRate ?: 0f) * if (v.isInterlaced) 2 else 1
val heavy = pixels > 1920 * 1088 || (v.bitDepth ?: 8) > 8 || (fps > MAX_LIGHT_FPS && pixels * fps > MAX_LIGHT_PIXEL_RATE)
losses += if (heavy) Loss.SOFTWARE_DECODE_HEAVY else Loss.SOFTWARE_DECODE
notes += v.codec.orEmpty()
}
when {
@@ -92,5 +96,8 @@ class VlcEngineProvider @Inject constructor(
companion object {
val ID = EngineId("vlc")
private const val MAX_BITRATE = 400_000_000
private const val MAX_LIGHT_FPS = 31f
/** Above 30 fps: SD at 50 (~21 Mpx/s) is light, 720p60 (~55 Mpx/s) is not. */
private const val MAX_LIGHT_PIXEL_RATE = 1280f * 720 * 50
}
}