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

@@ -40,7 +40,10 @@ enum class Loss(val weight: Int, val label: String) {
SOFTWARE_DECODE(15, "программное декодирование"),
DV_TO_BASE_LAYER(30, "Dolby Vision → базовый слой"),
HDR_UNVERIFIED(25, "HDR-вывод не проверен"),
ASS_SIMPLIFIED(20, "ASS упрощён"),
/** Outweighs [SOFTWARE_DECODE]: playback stutters and stalls, not just a quality loss. */
BROKEN_AUDIO_TIMING(30, "сбивается синхронизация звука"),
/** Small: TV SoCs usually deinterlace in the display pipeline after the hardware decoder. */
NO_DEINTERLACE(5, "без деинтерлейса в плеере"),
/** Outweighs [SOFTWARE_DECODE]: visible combing in motion, not a subtle quality loss. */
DVD_COMBING(30, "гребёнка на чересстрочном DVD"),
}

View File

@@ -35,6 +35,8 @@ data class EngineError(
AUDIO_OUTPUT,
NETWORK,
NO_VIDEO_OUTPUT,
/** Playing but not progressing, or dropping a large share of frames (seen by the session's stall watchdog). */
STALLED,
UNKNOWN,
}

View File

@@ -57,6 +57,14 @@ interface VideoPathSwitchable {
fun setVideoPathMode(mode: Mode)
}
/**
* Dolby Vision files with a base layer can be decoded either as that base layer (HDR10/HLG, the default) or through
* the Dolby Vision decoder, which switches the TV into its Dolby Vision mode. Must be set before [PlaybackEngine.load].
*/
interface DolbyVisionSwitchable {
var useDolbyVisionDecoder: Boolean
}
interface SubtitleDelayCapable {
fun setSubtitleDelay(delayMs: Long)
}

View File

@@ -27,7 +27,7 @@ class EngineSelector @Inject constructor(
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) {
fun score(verdict: Verdict): Int = when (verdict) {
is Verdict.Direct -> 0
is Verdict.Degraded -> verdict.losses.sumOf(Loss::weight)
is Verdict.No -> 10_000

View File

@@ -1,5 +1,7 @@
package ru.stenfredd.movies.playback.core
import android.net.Uri
import android.os.SystemClock
import android.widget.FrameLayout
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -30,6 +32,7 @@ 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.DolbyVisionSwitchable
import ru.stenfredd.movies.playback.api.EngineError
import ru.stenfredd.movies.playback.api.EngineEvent
import ru.stenfredd.movies.playback.api.EngineId
@@ -41,6 +44,7 @@ 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.VideoPathSwitchable
import ru.stenfredd.movies.playback.api.videoStream
import timber.log.Timber
import java.util.UUID
@@ -105,6 +109,13 @@ class PlaybackSession(
private var ranking: List<RankedEngine> = emptyList()
private val tried = mutableSetOf<EngineId>()
private var transcodeTried = false
private var nextVideoPath: VideoPathSwitchable.Mode? = null
private var nextDolbyVision: Boolean? = null
/** Set by [switchToAuto]: the next engine keeps its own default decoder choice instead of the old engine's. */
private var defaultDolbyVision = false
/** The user picked the engine: the stall watchdog only logs instead of switching away from their choice. */
private var manualEngine = false
private var lastSeekAt = 0L
private var playSessionId: String? = null
private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
@@ -121,14 +132,16 @@ class PlaybackSession(
container = null
}
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long) {
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) {
scope.launch {
runCatching {
val user = session.requireSession()
session.refreshLocalAddress()
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)
selection = streams ?: StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex)
val startMs = startFraction?.let { ((item.runTimeTicks ?: 0) / TICKS_PER_MS * it).toLong() } ?: startPositionMs
ranking = selector.rank(source, selection, device)
_state.update {
it.copy(
@@ -141,7 +154,8 @@ class PlaybackSession(
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)
manualEngine = forcedEngine != null
launchEngine(first, startMs, forceTranscode = false, reason = reason)
}.onFailure { fail("Не удалось начать воспроизведение", it) }
}
}
@@ -152,7 +166,7 @@ class PlaybackSession(
reportProgressAsync()
}
fun seekBy(deltaMs: Long) = seekTo((engine?.positionMs ?: 0) + deltaMs)
fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs)
fun seekTo(positionMs: Long) {
val e = engine ?: return
@@ -169,34 +183,74 @@ class PlaybackSession(
return
}
}
lastSeekAt = SystemClock.elapsedRealtime()
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("смена аудио")
onSelectionChanged("смена аудио") { it.selectAudio(streamIndex) }
}
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("смена субтитров")
onSelectionChanged("смена субтитров") { it.selectSubtitle(streamIndex) }
}
fun switchEngine(id: EngineId) {
val position = engine?.positionMs ?: 0
/**
* Tracks change what each engine can do (ASS subtitles, TrueHD audio, ...): re-rank, and in automatic mode move
* to an engine that is now strictly better. Engines that already failed in this session are not retried.
*/
private fun onSelectionChanged(what: String, applyInPlace: (PlaybackEngine) -> Unit) {
ranking = selector.rank(source, selection, device)
_state.update { it.copy(selection = selection, ranking = ranking.map { r -> r.provider.id to r.verdict }) }
val e = engine ?: return
val current = ranking.firstOrNull { it.provider.id == e.id }
val best = ranking.firstOrNull { it.provider.id == e.id || it.provider.id !in tried }
if (!manualEngine && current != null && best != null && best.provider.id != e.id &&
selector.score(best.verdict) < selector.score(current.verdict)
) {
val position = positionMs()
log("$what: ${best.provider.id} теперь подходит лучше (${best.verdict.describe()})")
scope.launch { launchEngine(best.provider, position, forceTranscode = false, reason = "$what: ${best.verdict.describe()}") }
return
}
if (playMethod == PlayMethod.DIRECT_PLAY) applyInPlace(e) else restartCurrent(what)
}
/** [videoPath] is applied to the new engine before it loads (if it is [VideoPathSwitchable]). */
fun switchEngine(id: EngineId, transcode: Boolean = false, videoPath: VideoPathSwitchable.Mode? = null, dolbyVision: Boolean? = null) {
val position = positionMs()
scope.launch {
tried.clear()
transcodeTried = false
launchEngine(selector.provider(id), position, forceTranscode = false, reason = "выбран вручную")
nextVideoPath = videoPath
nextDolbyVision = dolbyVision
manualEngine = true
launchEngine(selector.provider(id), position, forceTranscode = transcode, reason = if (transcode) "транскод вручную" else "выбран вручную")
}
}
/** Back to what the selector would pick for this file. */
fun switchToAuto() {
val first = ranking.firstOrNull() ?: return
val position = positionMs()
scope.launch {
tried.clear()
transcodeTried = false
nextVideoPath = VideoPathSwitchable.Mode.AUTO
nextDolbyVision = null
defaultDolbyVision = true
manualEngine = false
launchEngine(first.provider, position, forceTranscode = false, reason = first.verdict.describe())
}
}
fun forceTranscode() {
val position = engine?.positionMs ?: 0
val position = positionMs()
val provider = engine?.id?.let(selector::provider) ?: return
manualEngine = true
scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") }
}
@@ -211,7 +265,7 @@ class PlaybackSession(
private fun restartCurrent(why: String) {
val provider = engine?.id?.let(selector::provider) ?: return
val position = engine?.positionMs ?: 0
val position = positionMs()
scope.launch { launchEngine(provider, position, forceTranscode = playMethod != PlayMethod.DIRECT_PLAY, reason = why) }
}
@@ -220,10 +274,13 @@ class PlaybackSession(
engineJobs.forEach(Job::cancel)
engineJobs.clear()
engine?.let { old ->
reportStop(old.positionMs, failed = false)
if (nextVideoPath == null) nextVideoPath = (old as? VideoPathSwitchable)?.videoPathMode
if (nextDolbyVision == null && !defaultDolbyVision) nextDolbyVision = (old as? DolbyVisionSwitchable)?.useDolbyVisionDecoder
reportStop(positionMs(), failed = false)
old.release()
}
engine = null
launchPositionMs = startMs
tried += provider.id
if (forceTranscode) transcodeTried = true
_state.update { it.copy(loading = true, error = null, engine = provider.id, engineReason = reason) }
@@ -278,13 +335,19 @@ class PlaybackSession(
}
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 host = Uri.parse(url).let { "${it.scheme}://${it.authority}" }
log("${provider.id}: $playMethod через $host" + (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)
nextVideoPath?.let { (newEngine as? VideoPathSwitchable)?.setVideoPathMode(it) }
nextVideoPath = null
nextDolbyVision?.let { (newEngine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder = it }
nextDolbyVision = null
defaultDolbyVision = false
engine = newEngine
firstFrameSeen = false
container?.let(newEngine::attachVideo)
@@ -293,6 +356,7 @@ class PlaybackSession(
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
engineJobs += scope.launch { tickLoop(gen) }
engineJobs += scope.launch { firstFrameWatchdog(gen) }
engineJobs += scope.launch { stallWatchdog(gen) }
_state.update { it.copy(playMethod = playMethod, transcodeReasons = transcodeReasons) }
newEngine.load(
@@ -311,6 +375,18 @@ class PlaybackSession(
}
private var firstFrameSeen = false
/** Where the current engine was asked to start; see [positionMs]. */
private var launchPositionMs = 0L
/**
* Playback position for handing over to another engine. Until the engine shows its first frame its own position
* is meaningless (0 or -1, or relative to a transcode), so the position it was started at is used: otherwise a
* quick variant switch (or a fallback from an engine that never got going) restarts the film from the beginning.
*/
private fun positionMs(): Long {
val e = engine
return if (e == null || !firstFrameSeen) launchPositionMs else e.positionMs
}
private suspend fun firstFrameWatchdog(gen: Int) {
if (source.videoStream == null) return
@@ -320,6 +396,52 @@ class PlaybackSession(
}
}
/**
* Catches what the first-frame watchdog can't: an engine that started fine but then hangs (reports READY and
* playing, position stands still) or plays so badly it is useless (e.g. Exo on AC3-in-AVI with broken audio
* timing: ~40% of frames dropped). Buffering alone is not treated as a stall: that is usually the network, and
* another engine would not fetch faster.
*/
private suspend fun stallWatchdog(gen: Int) {
val video = source.videoStream ?: return
val fps = (video.realFrameRate ?: video.averageFrameRate ?: 25f).coerceIn(10f, 120f)
var lastPosition = -1L
var stuckMs = 0L
val drops = ArrayDeque<Int>()
while (scope.isActive && gen == generation) {
delay(STALL_TICK_MS)
val e = engine ?: return
val s = e.state.value
val settled = firstFrameSeen && SystemClock.elapsedRealtime() - lastSeekAt > SEEK_GRACE_MS
if (!s.playWhenReady || s.phase == EngineState.Phase.ENDED || !settled) {
lastPosition = -1
stuckMs = 0
drops.clear()
continue
}
val position = e.positionMs
stuckMs = if (s.phase == EngineState.Phase.READY && lastPosition >= 0 && position <= lastPosition) stuckMs + STALL_TICK_MS else 0
lastPosition = position
drops.addLast(_state.value.stats.droppedFrames)
if (drops.size > DROP_WINDOW_TICKS + 1) drops.removeFirst()
val dropped = if (drops.size > DROP_WINDOW_TICKS) drops.last() - drops.first() else 0
val windowS = DROP_WINDOW_TICKS * STALL_TICK_MS / 1000
val reason = when {
stuckMs >= STALL_TIMEOUT_MS -> "играет, но позиция стоит ${stuckMs / 1000} с"
dropped > fps * windowS * MAX_DROPPED_SHARE -> "пропущено $dropped кадров за $windowS с"
else -> continue
}
if (manualEngine) {
log("Застой ${e.id}: $reason (движок выбран вручную, не переключаю)")
stuckMs = 0
drops.clear()
continue
}
onEngineEvent(gen, EngineEvent.Error(EngineError(EngineError.Kind.STALLED, "Застой: $reason")))
return
}
}
private fun onEngineEvent(gen: Int, event: EngineEvent) {
if (gen != generation) return
when (event) {
@@ -329,7 +451,7 @@ class PlaybackSession(
}
is EngineEvent.Error -> {
val failedEngine = engine?.id
val position = engine?.positionMs ?: 0
val position = positionMs()
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) {
@@ -354,7 +476,7 @@ class PlaybackSession(
var sinceReport = 0L
while (scope.isActive && gen == generation) {
val e = engine ?: return
_state.update { it.copy(positionMs = e.positionMs, durationMs = e.durationMs) }
_state.update { it.copy(positionMs = positionMs(), durationMs = e.durationMs) }
delay(TICK_MS)
sinceReport += TICK_MS
if (sinceReport >= PROGRESS_INTERVAL_MS) {
@@ -468,5 +590,11 @@ class PlaybackSession(
const val TICK_MS = 500L
const val PROGRESS_INTERVAL_MS = 10_000L
const val FIRST_FRAME_TIMEOUT_MS = 60_000L
const val STALL_TICK_MS = 1000L
const val STALL_TIMEOUT_MS = 8_000L
/** Seeking rebuffers and drops a few frames: give the engine time to settle before judging it. */
const val SEEK_GRACE_MS = 5_000L
const val DROP_WINDOW_TICKS = 10
const val MAX_DROPPED_SHARE = 0.25
}
}

View File

@@ -19,6 +19,8 @@ dependencies {
implementation(libs.media3.exoplayer)
implementation(libs.media3.exoplayer.hls)
implementation(libs.media3.ui)
implementation(libs.ass.media)
implementation(libs.jellyfin.ffmpeg)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)

View File

@@ -0,0 +1,129 @@
package ru.stenfredd.movies.playback.exo.ps;
import android.util.SparseArray;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.ts.Ac3Reader;
import androidx.media3.extractor.ts.ElementaryStreamReader;
import androidx.media3.extractor.ts.TsPayloadReader;
/**
* Reader for MPEG-PS private stream 1 (0xBD) as DVDs use it: every payload starts with a substream header (id, number
* of frame headers, 2-byte first access unit pointer), and one stream id carries all AC3 tracks (0x80-0x87), DTS
* (0x88-0x8F), LPCM (0xA0-0xA7) and subpictures (0x20-0x3F).
*
* <p>Each AC3 substream gets its own {@link Ac3Reader} and track, so the usual track selection picks the language;
* other substreams are dropped. A stream without substream headers (raw AC3 starting with the 0B 77 sync word) goes
* to a single reader unchanged.
*
* <p>Tracks can only be created before {@link DvdPsExtractor} ends the track list; substreams that first show up later
* are ignored.
*/
@UnstableApi
/* package */ final class DvdPrivateStreamReader implements ElementaryStreamReader {
private static final int MODE_UNKNOWN = 0;
private static final int MODE_SUBSTREAMS = 1;
private static final int MODE_RAW = 2;
private static final int RAW_KEY = -1;
private final SparseArray<Ac3Reader> readers = new SparseArray<>();
@Nullable private ExtractorOutput output;
private int mode = MODE_UNKNOWN;
private boolean tracksEnded;
private long pesTimeUs = C.TIME_UNSET;
private int pesFlags;
@Nullable private Ac3Reader current;
/** Called by {@link DvdPsExtractor} when it ends the track list. */
public void onTracksEnded() {
tracksEnded = true;
}
@Override
public void seek() {
for (int i = 0; i < readers.size(); i++) {
readers.valueAt(i).seek();
}
current = null;
}
@Override
public void createTracks(ExtractorOutput extractorOutput, TsPayloadReader.TrackIdGenerator idGenerator) {
// Tracks are created per substream as they appear.
output = extractorOutput;
}
@Override
public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) {
this.pesTimeUs = pesTimeUs;
this.pesFlags = flags;
}
@Override
public void consume(ParsableByteArray data) throws ParserException {
current = null;
if (data.bytesLeft() < 4) {
return;
}
byte[] bytes = data.getData();
int position = data.getPosition();
if (mode == MODE_UNKNOWN) {
boolean ac3Sync = (bytes[position] & 0xFF) == 0x0B && (bytes[position + 1] & 0xFF) == 0x77;
mode = ac3Sync ? MODE_RAW : MODE_SUBSTREAMS;
}
int key;
if (mode == MODE_RAW) {
key = RAW_KEY;
} else {
key = bytes[position] & 0xFF;
if (key < 0x80 || key > 0x87) {
return;
}
}
@Nullable Ac3Reader reader = readerFor(key);
if (reader == null) {
return;
}
if (mode == MODE_SUBSTREAMS) {
data.skipBytes(4);
}
current = reader;
reader.packetStarted(pesTimeUs, pesFlags);
reader.consume(data);
}
@Override
public void packetFinished() {
if (current != null) {
current.packetFinished();
}
current = null;
}
@Override
public void endOfInputReached() {
for (int i = 0; i < readers.size(); i++) {
readers.valueAt(i).endOfInputReached();
}
}
@Nullable
private Ac3Reader readerFor(int key) {
@Nullable Ac3Reader reader = readers.get(key);
if (reader != null || tracksEnded || output == null) {
return reader;
}
reader = new Ac3Reader(MimeTypes.VIDEO_PS);
// Track ids above the PS stream id range (0..0xFF) used by DvdPsExtractor for the other streams.
int trackId = key == RAW_KEY ? DvdPsExtractor.PRIVATE_STREAM_1 : 0x100 + key;
reader.createTracks(output, new TsPayloadReader.TrackIdGenerator(trackId, 0x200));
readers.put(key, reader);
return reader;
}
}

View File

@@ -0,0 +1,447 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Modified for the Movies app (2026-10-13), copied from media3 1.11.1 androidx.media3.extractor.ts:
* see DvdPsExtractor for what changed.
*/
package ru.stenfredd.movies.playback.exo.ps;
import static com.google.common.base.Preconditions.checkNotNull;
import android.util.SparseArray;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.ParsableBitArray;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.TimestampAdjuster;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.ExtractorsFactory;
import androidx.media3.extractor.PositionHolder;
import androidx.media3.extractor.SeekMap;
import androidx.media3.extractor.ts.ElementaryStreamReader;
import androidx.media3.extractor.ts.H262Reader;
import androidx.media3.extractor.ts.MpegAudioReader;
import androidx.media3.extractor.ts.TsPayloadReader;
import androidx.media3.extractor.ts.TsPayloadReader.TrackIdGenerator;
import java.io.IOException;
/**
* Extracts data from the MPEG-2 PS container format.
*
* <p>media3's {@code PsExtractor} with two fixes for DVD rips (VOB/MPG):
*
* <ul>
* <li>A PES packet without a PTS gets {@link C#TIME_UNSET} instead of 0. With 0, most pictures of
* every GOP came out at time 0 and were skipped (~1.5 fps on screen); see also
* Mpeg2TimestampFixExtractor for the decode-order extrapolation in H262Reader.
* <li>DVD private stream 1 starts every payload with a substream header (id, frame count, first
* access unit pointer). The original fed it, and the subpicture/DTS/LPCM substreams that share
* the stream id, straight into one AC3 reader: the 4 header bytes ended up inside AC3 frames
* that span two packets, giving "quacking" audio, and several audio tracks were mixed. Here
* {@link DvdPrivateStreamReader} strips the header and gives every AC3 substream its own track.
* </ul>
*/
@UnstableApi
public final class DvdPsExtractor implements Extractor {
/** Factory for {@link DvdPsExtractor} instances. */
public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new DvdPsExtractor()};
/* package */ static final int PACK_START_CODE = 0x000001BA;
/* package */ static final int SYSTEM_HEADER_START_CODE = 0x000001BB;
/* package */ static final int PACKET_START_CODE_PREFIX = 0x000001;
/* package */ static final int MPEG_PROGRAM_END_CODE = 0x000001B9;
private static final int MAX_STREAM_ID_PLUS_ONE = 0x100;
// Max search length for first audio and video track in input data.
private static final long MAX_SEARCH_LENGTH = 1024 * 1024;
// Max search length for additional audio and video tracks in input data after at least one audio
// and video track has been found.
private static final long MAX_SEARCH_LENGTH_AFTER_AUDIO_AND_VIDEO_FOUND = 8 * 1024;
public static final int PRIVATE_STREAM_1 = 0xBD;
public static final int AUDIO_STREAM = 0xC0;
public static final int AUDIO_STREAM_MASK = 0xE0;
public static final int VIDEO_STREAM = 0xE0;
public static final int VIDEO_STREAM_MASK = 0xF0;
private final TimestampAdjuster timestampAdjuster;
private final SparseArray<PesReader> psPayloadReaders; // Indexed by pid
private final ParsableByteArray psPacketBuffer;
private final PsDurationReader durationReader;
private boolean foundAllTracks;
@Nullable private DvdPrivateStreamReader privateStreamReader;
private boolean foundAudioTrack;
private boolean foundVideoTrack;
private long lastTrackPosition;
// Accessed only by the loading thread.
@Nullable private PsBinarySearchSeeker psBinarySearchSeeker;
private ExtractorOutput output;
private boolean hasOutputSeekMap;
public DvdPsExtractor() {
this(new TimestampAdjuster(0));
}
public DvdPsExtractor(TimestampAdjuster timestampAdjuster) {
this.timestampAdjuster = timestampAdjuster;
psPacketBuffer = new ParsableByteArray(4096);
psPayloadReaders = new SparseArray<>();
durationReader = new PsDurationReader();
}
// Extractor implementation.
@Override
public boolean sniff(ExtractorInput input) throws IOException {
byte[] scratch = new byte[14];
input.peekFully(scratch, 0, 14);
// Verify the PACK_START_CODE for the first 4 bytes
if (PACK_START_CODE
!= (((scratch[0] & 0xFF) << 24)
| ((scratch[1] & 0xFF) << 16)
| ((scratch[2] & 0xFF) << 8)
| (scratch[3] & 0xFF))) {
return false;
}
// Verify the 01xxx1xx marker on the 5th byte
if ((scratch[4] & 0xC4) != 0x44) {
return false;
}
// Verify the xxxxx1xx marker on the 7th byte
if ((scratch[6] & 0x04) != 0x04) {
return false;
}
// Verify the xxxxx1xx marker on the 9th byte
if ((scratch[8] & 0x04) != 0x04) {
return false;
}
// Verify the xxxxxxx1 marker on the 10th byte
if ((scratch[9] & 0x01) != 0x01) {
return false;
}
// Verify the xxxxxx11 marker on the 13th byte
if ((scratch[12] & 0x03) != 0x03) {
return false;
}
// Read the stuffing length from the 14th byte (last 3 bits)
int packStuffingLength = scratch[13] & 0x07;
input.advancePeekPosition(packStuffingLength);
// Now check that the next 3 bytes are the beginning of an MPEG start code
input.peekFully(scratch, 0, 3);
return (PACKET_START_CODE_PREFIX
== (((scratch[0] & 0xFF) << 16) | ((scratch[1] & 0xFF) << 8) | (scratch[2] & 0xFF)));
}
@Override
public void init(ExtractorOutput output) {
this.output = output;
}
@Override
public void seek(long position, long timeUs) {
// If the timestamp adjuster has not yet established a timestamp offset, we need to reset its
// expected first sample timestamp to be the new seek position. Without this, the timestamp
// adjuster would incorrectly establish its timestamp offset assuming that the first sample
// after this seek corresponds to the start of the stream (or a previous seek position, if there
// was one).
boolean resetTimestampAdjuster = timestampAdjuster.getTimestampOffsetUs() == C.TIME_UNSET;
if (!resetTimestampAdjuster) {
long adjusterFirstSampleTimestampUs = timestampAdjuster.getFirstSampleTimestampUs();
// Also reset the timestamp adjuster if its offset was calculated based on a non-zero position
// in the stream (other than the position being seeked to), since in this case the offset may
// not be accurate.
resetTimestampAdjuster =
adjusterFirstSampleTimestampUs != C.TIME_UNSET
&& adjusterFirstSampleTimestampUs != 0
&& adjusterFirstSampleTimestampUs != timeUs;
}
if (resetTimestampAdjuster) {
timestampAdjuster.reset(timeUs);
}
if (psBinarySearchSeeker != null) {
psBinarySearchSeeker.setSeekTargetUs(timeUs);
}
for (int i = 0; i < psPayloadReaders.size(); i++) {
psPayloadReaders.valueAt(i).seek();
}
}
@Override
public void release() {
// Do nothing
}
@Override
public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException {
// Asserts init has been called.
checkNotNull(output);
long inputLength = input.getLength();
boolean canReadDuration = inputLength != C.LENGTH_UNSET;
if (canReadDuration && !durationReader.isDurationReadFinished()) {
return durationReader.readDuration(input, seekPosition);
}
maybeOutputSeekMap(inputLength);
if (psBinarySearchSeeker != null && psBinarySearchSeeker.isSeeking()) {
return psBinarySearchSeeker.handlePendingSeek(input, seekPosition);
}
input.resetPeekPosition();
long peekBytesLeft =
inputLength != C.LENGTH_UNSET ? inputLength - input.getPeekPosition() : C.LENGTH_UNSET;
if (peekBytesLeft != C.LENGTH_UNSET && peekBytesLeft < 4) {
onEndOfInput();
return RESULT_END_OF_INPUT;
}
// First peek and check what type of start code is next.
if (!input.peekFully(psPacketBuffer.getData(), 0, 4, true)) {
onEndOfInput();
return RESULT_END_OF_INPUT;
}
psPacketBuffer.setPosition(0);
int nextStartCode = psPacketBuffer.readInt();
if (nextStartCode == MPEG_PROGRAM_END_CODE) {
onEndOfInput();
return RESULT_END_OF_INPUT;
} else if (nextStartCode == PACK_START_CODE) {
// Now peek the rest of the pack_header.
input.peekFully(psPacketBuffer.getData(), 0, 10);
// We only care about the pack_stuffing_length in here, skip the first 77 bits.
psPacketBuffer.setPosition(9);
// Last 3 bits is the length.
int packStuffingLength = psPacketBuffer.readUnsignedByte() & 0x07;
// Now skip the stuffing and the pack header.
input.skipFully(packStuffingLength + 14);
return RESULT_CONTINUE;
} else if (nextStartCode == SYSTEM_HEADER_START_CODE) {
// We just skip all this, but we need to get the length first.
input.peekFully(psPacketBuffer.getData(), 0, 2);
// Length is the next 2 bytes.
psPacketBuffer.setPosition(0);
int systemHeaderLength = psPacketBuffer.readUnsignedShort();
input.skipFully(systemHeaderLength + 6);
return RESULT_CONTINUE;
} else if (((nextStartCode & 0xFFFFFF00) >> 8) != PACKET_START_CODE_PREFIX) {
input.skipFully(1); // Skip bytes until we see a valid start code again.
return RESULT_CONTINUE;
}
// We're at the start of a regular PES packet now.
// Get the stream ID off the last byte of the start code.
int streamId = nextStartCode & 0xFF;
// Check to see if we have this one in our map yet, and if not, then add it.
PesReader payloadReader = psPayloadReaders.get(streamId);
if (!foundAllTracks) {
if (payloadReader == null) {
@Nullable ElementaryStreamReader elementaryStreamReader = null;
if (streamId == PRIVATE_STREAM_1) {
// Private stream: AC3 audio, on DVDs split into substreams.
privateStreamReader = new DvdPrivateStreamReader();
elementaryStreamReader = privateStreamReader;
foundAudioTrack = true;
lastTrackPosition = input.getPosition();
} else if ((streamId & AUDIO_STREAM_MASK) == AUDIO_STREAM) {
elementaryStreamReader = new MpegAudioReader(MimeTypes.VIDEO_PS);
foundAudioTrack = true;
lastTrackPosition = input.getPosition();
} else if ((streamId & VIDEO_STREAM_MASK) == VIDEO_STREAM) {
elementaryStreamReader = new H262Reader(MimeTypes.VIDEO_PS);
foundVideoTrack = true;
lastTrackPosition = input.getPosition();
}
if (elementaryStreamReader != null) {
TrackIdGenerator idGenerator = new TrackIdGenerator(streamId, MAX_STREAM_ID_PLUS_ONE);
elementaryStreamReader.createTracks(output, idGenerator);
payloadReader = new PesReader(elementaryStreamReader, timestampAdjuster);
psPayloadReaders.put(streamId, payloadReader);
}
}
// DVD audio substreams are interleaved over the first seconds: keep looking for them over the
// whole initial search window instead of stopping 8 KB after the first audio packet.
long maxSearchPosition =
foundAudioTrack && foundVideoTrack && privateStreamReader == null
? lastTrackPosition + MAX_SEARCH_LENGTH_AFTER_AUDIO_AND_VIDEO_FOUND
: MAX_SEARCH_LENGTH;
if (input.getPosition() > maxSearchPosition) {
foundAllTracks = true;
if (privateStreamReader != null) {
privateStreamReader.onTracksEnded();
}
output.endTracks();
}
}
// The next 2 bytes are the length. Once we have that we can consume the complete packet.
input.peekFully(psPacketBuffer.getData(), 0, 2);
psPacketBuffer.setPosition(0);
int payloadLength = psPacketBuffer.readUnsignedShort();
int pesLength = payloadLength + 6;
if (payloadReader == null) {
// Just skip this data.
input.skipFully(pesLength);
} else {
psPacketBuffer.reset(pesLength);
// Read the whole packet and the header for consumption.
input.readFully(psPacketBuffer.getData(), 0, pesLength);
psPacketBuffer.setPosition(6);
payloadReader.consume(psPacketBuffer);
psPacketBuffer.setLimit(psPacketBuffer.capacity());
}
return RESULT_CONTINUE;
}
// Internals.
private void onEndOfInput() {
for (int i = 0; i < psPayloadReaders.size(); i++) {
psPayloadReaders.valueAt(i).consumeEndOfInput();
}
}
private void maybeOutputSeekMap(long inputLength) {
if (!hasOutputSeekMap) {
hasOutputSeekMap = true;
if (durationReader.getDurationUs() != C.TIME_UNSET) {
psBinarySearchSeeker =
new PsBinarySearchSeeker(
durationReader.getScrTimestampAdjuster(),
durationReader.getDurationUs(),
inputLength);
output.seekMap(psBinarySearchSeeker.getSeekMap());
} else {
output.seekMap(new SeekMap.Unseekable(durationReader.getDurationUs()));
}
}
}
/** Parses PES packet data and extracts samples. */
private static final class PesReader {
private static final int PES_SCRATCH_SIZE = 64;
private final ElementaryStreamReader pesPayloadReader;
private final TimestampAdjuster timestampAdjuster;
private final ParsableBitArray pesScratch;
private boolean ptsFlag;
private boolean dtsFlag;
private boolean seenFirstDts;
private int extendedHeaderLength;
private long timeUs;
public PesReader(ElementaryStreamReader pesPayloadReader, TimestampAdjuster timestampAdjuster) {
this.pesPayloadReader = pesPayloadReader;
this.timestampAdjuster = timestampAdjuster;
pesScratch = new ParsableBitArray(new byte[PES_SCRATCH_SIZE]);
}
/**
* Notifies the reader that a seek has occurred.
*
* <p>Following a call to this method, the data passed to the next invocation of {@link
* #consume(ParsableByteArray)} will not be a continuation of the data that was previously
* passed. Hence the reader should reset any internal state.
*/
public void seek() {
seenFirstDts = false;
pesPayloadReader.seek();
}
/**
* Consumes the payload of a PS packet.
*
* @param data The PES packet. The position will be set to the start of the payload.
* @throws ParserException If the payload could not be parsed.
*/
public void consume(ParsableByteArray data) throws ParserException {
data.readBytes(pesScratch.data, 0, 3);
pesScratch.setPosition(0);
parseHeader();
data.readBytes(pesScratch.data, 0, extendedHeaderLength);
pesScratch.setPosition(0);
parseHeaderExtension();
pesPayloadReader.packetStarted(timeUs, TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR);
pesPayloadReader.consume(data);
// We always have complete PES packets with program stream.
pesPayloadReader.packetFinished();
}
private void consumeEndOfInput() {
pesPayloadReader.endOfInputReached();
}
private void parseHeader() {
// Note: see ISO/IEC 13818-1, section 2.4.3.6 for detailed information on the format of
// the header.
// First 8 bits are skipped: '10' (2), PES_scrambling_control (2), PES_priority (1),
// data_alignment_indicator (1), copyright (1), original_or_copy (1)
pesScratch.skipBits(8);
ptsFlag = pesScratch.readBit();
dtsFlag = pesScratch.readBit();
// ESCR_flag (1), ES_rate_flag (1), DSM_trick_mode_flag (1),
// additional_copy_info_flag (1), PES_CRC_flag (1), PES_extension_flag (1)
pesScratch.skipBits(6);
extendedHeaderLength = pesScratch.readBits(8);
}
private void parseHeaderExtension() {
timeUs = C.TIME_UNSET;
if (ptsFlag) {
pesScratch.skipBits(4); // '0010' or '0011'
long pts = (long) pesScratch.readBits(3) << 30;
pesScratch.skipBits(1); // marker_bit
pts |= pesScratch.readBits(15) << 15;
pesScratch.skipBits(1); // marker_bit
pts |= pesScratch.readBits(15);
pesScratch.skipBits(1); // marker_bit
if (!seenFirstDts && dtsFlag) {
pesScratch.skipBits(4); // '0011'
long dts = (long) pesScratch.readBits(3) << 30;
pesScratch.skipBits(1); // marker_bit
dts |= pesScratch.readBits(15) << 15;
pesScratch.skipBits(1); // marker_bit
dts |= pesScratch.readBits(15);
pesScratch.skipBits(1); // marker_bit
// Subsequent PES packets may have earlier presentation timestamps than this one, but they
// should all be greater than or equal to this packet's decode timestamp. We feed the
// decode timestamp to the adjuster here so that in the case that this is the first to be
// fed, the adjuster will be able to compute an offset to apply such that the adjusted
// presentation timestamps of all future packets are non-negative.
timestampAdjuster.adjustTsTimestamp(dts);
seenFirstDts = true;
}
timeUs = timestampAdjuster.adjustTsTimestamp(pts);
}
}
}
}

View File

@@ -0,0 +1,215 @@
/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Modified for the Movies app (2026-10-13), copied from media3 1.11.1 androidx.media3.extractor.ts:
* see DvdPsExtractor for what changed.
*/
package ru.stenfredd.movies.playback.exo.ps;
import static java.lang.Math.min;
import androidx.media3.common.C;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.TimestampAdjuster;
import androidx.media3.common.util.Util;
import androidx.media3.extractor.BinarySearchSeeker;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ts.TsExtractor;
import java.io.IOException;
/**
* A seeker that supports seeking within PS stream using binary search.
*
* <p>This seeker uses the first and last SCR values within the stream, as well as the stream
* duration to interpolate the SCR value of the seeking position. Then it performs binary search
* within the stream to find a packets whose SCR value is with in {@link #SEEK_TOLERANCE_US} from
* the target SCR.
*/
/* package */ final class PsBinarySearchSeeker extends BinarySearchSeeker {
private static final long SEEK_TOLERANCE_US = 100_000;
private static final int MINIMUM_SEARCH_RANGE_BYTES = 1000;
private static final int TIMESTAMP_SEARCH_BYTES = 20_000;
public PsBinarySearchSeeker(
TimestampAdjuster scrTimestampAdjuster, long streamDurationUs, long inputLength) {
super(
new DefaultSeekTimestampConverter(),
new PsScrSeeker(scrTimestampAdjuster),
streamDurationUs,
/* floorTimePosition= */ 0,
/* ceilingTimePosition= */ streamDurationUs + 1,
/* floorBytePosition= */ 0,
/* ceilingBytePosition= */ inputLength,
/* approxBytesPerFrame= */ TsExtractor.TS_PACKET_SIZE,
MINIMUM_SEARCH_RANGE_BYTES);
}
/**
* A seeker that looks for a given SCR timestamp at a given position in a PS stream.
*
* <p>Given a SCR timestamp, and a position within a PS stream, this seeker will peek up to {@link
* #TIMESTAMP_SEARCH_BYTES} bytes from that stream position, look for all packs in that range, and
* then compare the SCR timestamps (if available) of these packets to the target timestamp.
*/
private static final class PsScrSeeker implements TimestampSeeker {
private final TimestampAdjuster scrTimestampAdjuster;
private final ParsableByteArray packetBuffer;
private PsScrSeeker(TimestampAdjuster scrTimestampAdjuster) {
this.scrTimestampAdjuster = scrTimestampAdjuster;
packetBuffer = new ParsableByteArray();
}
@Override
public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp)
throws IOException {
long inputPosition = input.getPosition();
int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition);
packetBuffer.reset(bytesToSearch);
input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch);
return searchForScrValueInBuffer(packetBuffer, targetTimestamp, inputPosition);
}
@Override
public void onSeekFinished() {
packetBuffer.reset(Util.EMPTY_BYTE_ARRAY);
}
private TimestampSearchResult searchForScrValueInBuffer(
ParsableByteArray packetBuffer, long targetScrTimeUs, long bufferStartOffset) {
int startOfLastPacketPosition = C.INDEX_UNSET;
int endOfLastPacketPosition = C.INDEX_UNSET;
long lastScrTimeUsInRange = C.TIME_UNSET;
while (packetBuffer.bytesLeft() >= 4) {
int nextStartCode = peekIntAtPosition(packetBuffer.getData(), packetBuffer.getPosition());
if (nextStartCode != DvdPsExtractor.PACK_START_CODE) {
packetBuffer.skipBytes(1);
continue;
} else {
packetBuffer.skipBytes(4);
}
// We found a pack.
long scrValue = PsDurationReader.readScrValueFromPack(packetBuffer);
if (scrValue != C.TIME_UNSET) {
long scrTimeUs = scrTimestampAdjuster.adjustTsTimestamp(scrValue);
if (scrTimeUs > targetScrTimeUs) {
if (lastScrTimeUsInRange == C.TIME_UNSET) {
// First SCR timestamp is already over target.
return TimestampSearchResult.overestimatedResult(scrTimeUs, bufferStartOffset);
} else {
// Last SCR timestamp < target timestamp < this timestamp.
return TimestampSearchResult.targetFoundResult(
bufferStartOffset + startOfLastPacketPosition);
}
} else if (scrTimeUs + SEEK_TOLERANCE_US > targetScrTimeUs) {
long startOfPacketInStream = bufferStartOffset + packetBuffer.getPosition();
return TimestampSearchResult.targetFoundResult(startOfPacketInStream);
}
lastScrTimeUsInRange = scrTimeUs;
startOfLastPacketPosition = packetBuffer.getPosition();
}
skipToEndOfCurrentPack(packetBuffer);
endOfLastPacketPosition = packetBuffer.getPosition();
}
if (lastScrTimeUsInRange != C.TIME_UNSET) {
long endOfLastPacketPositionInStream = bufferStartOffset + endOfLastPacketPosition;
return TimestampSearchResult.underestimatedResult(
lastScrTimeUsInRange, endOfLastPacketPositionInStream);
} else {
return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT;
}
}
/**
* Skips the buffer position to the position after the end of the current PS pack in the buffer,
* given the byte position right after the {@link DvdPsExtractor#PACK_START_CODE} of the pack in
* the buffer. If the pack ends after the end of the buffer, skips to the end of the buffer.
*/
private static void skipToEndOfCurrentPack(ParsableByteArray packetBuffer) {
int limit = packetBuffer.limit();
if (packetBuffer.bytesLeft() < 10) {
// We require at least 9 bytes for pack header to read SCR value + 1 byte for pack_stuffing
// length.
packetBuffer.setPosition(limit);
return;
}
packetBuffer.skipBytes(9);
int packStuffingLength = packetBuffer.readUnsignedByte() & 0x07;
if (packetBuffer.bytesLeft() < packStuffingLength) {
packetBuffer.setPosition(limit);
return;
}
packetBuffer.skipBytes(packStuffingLength);
if (packetBuffer.bytesLeft() < 4) {
packetBuffer.setPosition(limit);
return;
}
int nextStartCode = peekIntAtPosition(packetBuffer.getData(), packetBuffer.getPosition());
if (nextStartCode == DvdPsExtractor.SYSTEM_HEADER_START_CODE) {
packetBuffer.skipBytes(4);
int systemHeaderLength = packetBuffer.readUnsignedShort();
if (packetBuffer.bytesLeft() < systemHeaderLength) {
packetBuffer.setPosition(limit);
return;
}
packetBuffer.skipBytes(systemHeaderLength);
}
// Find the position of the next PACK_START_CODE or MPEG_PROGRAM_END_CODE, which is right
// after the end position of this pack.
// If we couldn't find these codes within the buffer, return the buffer limit, or return
// the first position which PES packets pattern does not match (some malformed packets).
while (packetBuffer.bytesLeft() >= 4) {
nextStartCode = peekIntAtPosition(packetBuffer.getData(), packetBuffer.getPosition());
if (nextStartCode == DvdPsExtractor.PACK_START_CODE
|| nextStartCode == DvdPsExtractor.MPEG_PROGRAM_END_CODE) {
break;
}
if (nextStartCode >>> 8 != DvdPsExtractor.PACKET_START_CODE_PREFIX) {
break;
}
packetBuffer.skipBytes(4);
if (packetBuffer.bytesLeft() < 2) {
// 2 bytes for PES_packet length.
packetBuffer.setPosition(limit);
return;
}
int pesPacketLength = packetBuffer.readUnsignedShort();
packetBuffer.setPosition(
min(packetBuffer.limit(), packetBuffer.getPosition() + pesPacketLength));
}
}
}
private static int peekIntAtPosition(byte[] data, int position) {
return (data[position] & 0xFF) << 24
| (data[position + 1] & 0xFF) << 16
| (data[position + 2] & 0xFF) << 8
| (data[position + 3] & 0xFF);
}
}

View File

@@ -0,0 +1,265 @@
/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Modified for the Movies app (2026-10-13), copied from media3 1.11.1 androidx.media3.extractor.ts:
* see DvdPsExtractor for what changed.
*/
package ru.stenfredd.movies.playback.exo.ps;
import static java.lang.Math.min;
import androidx.media3.common.C;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.TimestampAdjuster;
import androidx.media3.common.util.Util;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.PositionHolder;
import java.io.IOException;
/**
* A reader that can extract the approximate duration from a given MPEG program stream (PS).
*
* <p>This reader extracts the duration by reading system clock reference (SCR) values from the
* header of a pack at the start and at the end of the stream, calculating the difference, and
* converting that into stream duration. This reader also handles the case when a single SCR
* wraparound takes place within the stream, which can make SCR values at the beginning of the
* stream larger than SCR values at the end. This class can only be used once to read duration from
* a given stream, and the usage of the class is not thread-safe, so all calls should be made from
* the same thread.
*
* <p>Note: See ISO/IEC 13818-1, Table 2-33 for details of the SCR field in pack_header.
*/
/* package */ final class PsDurationReader {
private static final String TAG = "PsDurationReader";
private static final int TIMESTAMP_SEARCH_BYTES = 20_000;
private final TimestampAdjuster scrTimestampAdjuster;
private final ParsableByteArray packetBuffer;
private boolean isDurationRead;
private boolean isFirstScrValueRead;
private boolean isLastScrValueRead;
private long firstScrValue;
private long lastScrValue;
private long durationUs;
/* package */ PsDurationReader() {
scrTimestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0);
firstScrValue = C.TIME_UNSET;
lastScrValue = C.TIME_UNSET;
durationUs = C.TIME_UNSET;
packetBuffer = new ParsableByteArray();
}
/** Returns true if a PS duration has been read. */
public boolean isDurationReadFinished() {
return isDurationRead;
}
public TimestampAdjuster getScrTimestampAdjuster() {
return scrTimestampAdjuster;
}
/**
* Reads a PS duration from the input.
*
* <p>This reader reads the duration by reading SCR values from the header of a pack at the start
* and at the end of the stream, calculating the difference, and converting that into stream
* duration.
*
* @param input The {@link ExtractorInput} from which data should be read.
* @param seekPositionHolder If {@link Extractor#RESULT_SEEK} is returned, this holder is updated
* to hold the position of the required seek.
* @return One of the {@code RESULT_} values defined in {@link Extractor}.
* @throws IOException If an error occurred reading from the input.
*/
public @Extractor.ReadResult int readDuration(
ExtractorInput input, PositionHolder seekPositionHolder) throws IOException {
if (!isLastScrValueRead) {
return readLastScrValue(input, seekPositionHolder);
}
if (lastScrValue == C.TIME_UNSET) {
return finishReadDuration(input);
}
if (!isFirstScrValueRead) {
return readFirstScrValue(input, seekPositionHolder);
}
if (firstScrValue == C.TIME_UNSET) {
return finishReadDuration(input);
}
long minScrPositionUs = scrTimestampAdjuster.adjustTsTimestamp(firstScrValue);
long maxScrPositionUs =
scrTimestampAdjuster.adjustTsTimestampGreaterThanPreviousTimestamp(lastScrValue);
durationUs = maxScrPositionUs - minScrPositionUs;
return finishReadDuration(input);
}
/** Returns the duration last read from {@link #readDuration(ExtractorInput, PositionHolder)}. */
public long getDurationUs() {
return durationUs;
}
/**
* Returns the SCR value read from the next pack in the stream, given the buffer at the pack
* header start position (just behind the pack start code).
*/
public static long readScrValueFromPack(ParsableByteArray packetBuffer) {
int originalPosition = packetBuffer.getPosition();
if (packetBuffer.bytesLeft() < 9) {
// We require at 9 bytes for pack header to read scr value
return C.TIME_UNSET;
}
byte[] scrBytes = new byte[9];
packetBuffer.readBytes(scrBytes, /* offset= */ 0, scrBytes.length);
packetBuffer.setPosition(originalPosition);
if (!checkMarkerBits(scrBytes)) {
return C.TIME_UNSET;
}
return readScrValueFromPackHeader(scrBytes);
}
private int finishReadDuration(ExtractorInput input) {
packetBuffer.reset(Util.EMPTY_BYTE_ARRAY);
isDurationRead = true;
input.resetPeekPosition();
return Extractor.RESULT_CONTINUE;
}
private int readFirstScrValue(ExtractorInput input, PositionHolder seekPositionHolder)
throws IOException {
int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, input.getLength());
int searchStartPosition = 0;
if (input.getPosition() != searchStartPosition) {
seekPositionHolder.position = searchStartPosition;
return Extractor.RESULT_SEEK;
}
packetBuffer.reset(bytesToSearch);
input.resetPeekPosition();
input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch);
firstScrValue = readFirstScrValueFromBuffer(packetBuffer);
isFirstScrValueRead = true;
return Extractor.RESULT_CONTINUE;
}
private long readFirstScrValueFromBuffer(ParsableByteArray packetBuffer) {
int searchStartPosition = packetBuffer.getPosition();
int searchEndPosition = packetBuffer.limit();
for (int searchPosition = searchStartPosition;
searchPosition < searchEndPosition - 3;
searchPosition++) {
int nextStartCode = peekIntAtPosition(packetBuffer.getData(), searchPosition);
if (nextStartCode == DvdPsExtractor.PACK_START_CODE) {
packetBuffer.setPosition(searchPosition + 4);
long scrValue = readScrValueFromPack(packetBuffer);
if (scrValue != C.TIME_UNSET) {
return scrValue;
}
}
}
return C.TIME_UNSET;
}
private int readLastScrValue(ExtractorInput input, PositionHolder seekPositionHolder)
throws IOException {
long inputLength = input.getLength();
int bytesToSearch = (int) min(TIMESTAMP_SEARCH_BYTES, inputLength);
long searchStartPosition = inputLength - bytesToSearch;
if (input.getPosition() != searchStartPosition) {
seekPositionHolder.position = searchStartPosition;
return Extractor.RESULT_SEEK;
}
packetBuffer.reset(bytesToSearch);
input.resetPeekPosition();
input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch);
lastScrValue = readLastScrValueFromBuffer(packetBuffer);
isLastScrValueRead = true;
return Extractor.RESULT_CONTINUE;
}
private long readLastScrValueFromBuffer(ParsableByteArray packetBuffer) {
int searchStartPosition = packetBuffer.getPosition();
int searchEndPosition = packetBuffer.limit();
for (int searchPosition = searchEndPosition - 4;
searchPosition >= searchStartPosition;
searchPosition--) {
int nextStartCode = peekIntAtPosition(packetBuffer.getData(), searchPosition);
if (nextStartCode == DvdPsExtractor.PACK_START_CODE) {
packetBuffer.setPosition(searchPosition + 4);
long scrValue = readScrValueFromPack(packetBuffer);
if (scrValue != C.TIME_UNSET) {
return scrValue;
}
}
}
return C.TIME_UNSET;
}
private int peekIntAtPosition(byte[] data, int position) {
return (data[position] & 0xFF) << 24
| (data[position + 1] & 0xFF) << 16
| (data[position + 2] & 0xFF) << 8
| (data[position + 3] & 0xFF);
}
private static boolean checkMarkerBits(byte[] scrBytes) {
// Verify the 01xxx1xx marker on the 0th byte
if ((scrBytes[0] & 0xC4) != 0x44) {
return false;
}
// 1st byte belongs to scr field.
// Verify the xxxxx1xx marker on the 2nd byte
if ((scrBytes[2] & 0x04) != 0x04) {
return false;
}
// 3rd byte belongs to scr field.
// Verify the xxxxx1xx marker on the 4rd byte
if ((scrBytes[4] & 0x04) != 0x04) {
return false;
}
// Verify the xxxxxxx1 marker on the 5th byte
if ((scrBytes[5] & 0x01) != 0x01) {
return false;
}
// 6th and 7th bytes belongs to program_max_rate field.
// Verify the xxxxxx11 marker on the 8th byte
return (scrBytes[8] & 0x03) == 0x03;
}
/**
* Returns the value of SCR base - 33 bits in big endian order from the PS pack header, ignoring
* the marker bits. Note: See ISO/IEC 13818-1, Table 2-33 for details of the SCR field in
* pack_header.
*
* <p>We ignore SCR Ext, because it's too small to have any significance.
*/
private static long readScrValueFromPackHeader(byte[] scrBytes) {
return ((scrBytes[0] & 0b00111000L) >> 3) << 30
| (scrBytes[0] & 0b00000011L) << 28
| (scrBytes[1] & 0xFFL) << 20
| ((scrBytes[2] & 0b11111000L) >> 3) << 15
| (scrBytes[2] & 0b00000011L) << 13
| (scrBytes[3] & 0xFFL) << 5
| (scrBytes[4] & 0b11111000L) >> 3;
}
}

View File

@@ -0,0 +1,163 @@
package ru.stenfredd.movies.playback.exo
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.Ac3Util
import androidx.media3.extractor.DtsUtil
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import timber.log.Timber
import java.io.EOFException
import java.nio.ByteBuffer
/**
* Re-frames AC3/E-AC3/DTS audio from [androidx.media3.extractor.avi.AviExtractor].
*
* AviExtractor hands every AVI chunk to the decoder as one sample, but muxers cut compressed audio into chunks of
* their own size (1280 bytes there) that don't match the codec frames (1024 bytes for that AC3). The audio sink
* then takes each sample for one frame (32 ms) while the chunk times advance by 40 ms: endless
* `UnexpectedDiscontinuityException`, stutter and rebuffering, and through passthrough frames split across samples.
*
* AviExtractor's chunk times come from the chunk index (duration × index / chunk count), which drifts when chunk
* sizes vary (a large preload chunk at the start: 0.5 s jump). The audio is CBR, so only the first chunk after
* start or seek is used as the anchor, and each frame gets `anchor + bytes since it × frame duration / frame size`.
* Other tracks pass straight through.
*/
@UnstableApi
internal class AviAudioFramingExtractor(private val delegate: Extractor) : Extractor {
private val framers = mutableListOf<FramingTrackOutput>()
override fun sniff(input: ExtractorInput) = delegate.sniff(input)
override fun read(input: ExtractorInput, seekPosition: PositionHolder) = delegate.read(input, seekPosition)
override fun getUnderlyingImplementation() = delegate
override fun release() = delegate.release()
override fun init(output: ExtractorOutput) = delegate.init(object : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput {
val track = output.track(id, type)
return if (type == C.TRACK_TYPE_AUDIO) FramingTrackOutput(track).also(framers::add) else track
}
override fun endTracks() = output.endTracks()
override fun seekMap(seekMap: SeekMap) = output.seekMap(seekMap)
})
override fun seek(position: Long, timeUs: Long) {
framers.forEach(FramingTrackOutput::reset)
delegate.seek(position, timeUs)
}
private class FramingTrackOutput(private val out: TrackOutput) : TrackOutput {
private enum class Codec { AC3, DTS }
private var codec: Codec? = null
private var sampleRate = 0
/** Bytes of the current chunk followed by the unframed rest of the previous ones. */
private var pending = ByteArray(64 * 1024)
private var pendingLength = 0
/** Stream byte position of pending[0]. */
private var pendingStart = 0L
/** Time of the first chunk after start or seek, which begins at stream byte 0. */
private var anchorTimeUs = C.TIME_UNSET
private var resyncs = 0L
fun reset() {
pendingLength = 0
pendingStart = 0
anchorTimeUs = C.TIME_UNSET
}
override fun format(format: Format) {
codec = when (format.sampleMimeType) {
MimeTypes.AUDIO_AC3, MimeTypes.AUDIO_E_AC3 -> Codec.AC3
MimeTypes.AUDIO_DTS -> Codec.DTS
else -> null
}
sampleRate = format.sampleRate
if (sampleRate <= 0) codec = null
out.format(format)
}
override fun durationUs(durationUs: Long) = out.durationUs(durationUs)
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
if (codec == null) return out.sampleData(input, length, allowEndOfInput, sampleDataPart)
ensureCapacity(length)
val read = input.read(pending, pendingLength, length)
if (read == C.RESULT_END_OF_INPUT) {
if (allowEndOfInput) return read
throw EOFException()
}
pendingLength += read
return read
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (codec == null) return out.sampleData(data, length, sampleDataPart)
ensureCapacity(length)
data.readBytes(pending, pendingLength, length)
pendingLength += length
}
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
val codec = codec ?: return out.sampleMetadata(timeUs, flags, size, offset, cryptoData)
if (anchorTimeUs == C.TIME_UNSET) anchorTimeUs = timeUs
var i = 0
while (pendingLength - i >= HEADER_BYTES) {
val frameSize = frameSize(codec, i)
if (frameSize <= 0) {
if (resyncs++ % 1000 == 0L) Timber.w("AVI %s: no sync word, skipping a byte (%d)", codec, resyncs)
i++
continue
}
if (pendingLength - i < frameSize) break
val frameTimeUs = anchorTimeUs + (pendingStart + i) * frameDurationUs(codec, i) / frameSize
out.sampleData(ParsableByteArray(pending.copyOfRange(i, i + frameSize)), frameSize)
out.sampleMetadata(frameTimeUs, C.BUFFER_FLAG_KEY_FRAME, frameSize, 0, null)
i += frameSize
}
System.arraycopy(pending, i, pending, 0, pendingLength - i)
pendingLength -= i
pendingStart += i
}
private fun frameSize(codec: Codec, at: Int): Int = when (codec) {
Codec.AC3 -> if (pending[at] == 0x0B.toByte() && pending[at + 1] == 0x77.toByte()) {
Ac3Util.parseAc3SyncframeSize(pending.copyOfRange(at, at + HEADER_BYTES))
} else {
C.LENGTH_UNSET
}
Codec.DTS -> if (ByteBuffer.wrap(pending, at, 4).int in DTS_SYNC_WORDS) {
DtsUtil.getDtsFrameSize(pending.copyOfRange(at, at + HEADER_BYTES))
} else {
C.LENGTH_UNSET
}
}
private fun frameDurationUs(codec: Codec, at: Int): Long {
val samples = when (codec) {
Codec.AC3 -> Ac3Util.parseAc3SyncframeAudioSampleCount(ByteBuffer.wrap(pending, at, HEADER_BYTES).slice())
Codec.DTS -> DtsUtil.parseDtsAudioSampleCount(pending.copyOfRange(at, at + HEADER_BYTES))
}
return samples * C.MICROS_PER_SECOND / sampleRate
}
private fun ensureCapacity(extra: Int) {
if (pendingLength + extra > pending.size) pending = pending.copyOf(maxOf(pending.size * 2, pendingLength + extra))
}
private companion object {
/** Enough for the AC3/E-AC3 and DTS (incl. 14-bit) header fields the size/duration parsers read. */
const val HEADER_BYTES = 16
/** Core sync word: big-endian, little-endian, 14-bit big-endian, 14-bit little-endian. */
val DTS_SYNC_WORDS = setOf(0x7FFE8001, 0xFE7F0180.toInt(), 0x1FFFE800, 0xFF1F00E8.toInt())
}
}
}

View File

@@ -3,6 +3,11 @@
package ru.stenfredd.movies.playback.exo
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.Gravity
import android.view.SurfaceView
import android.view.TextureView
@@ -22,26 +27,38 @@ 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.DefaultLoadControl
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.source.DefaultMediaSourceFactory
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 io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.AssHandlerConfig
import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType
import io.github.peerless2012.ass.media.widget.AssSubtitleView
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 kotlin.math.abs
import kotlin.math.roundToInt
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.DolbyVisionSwitchable
import ru.stenfredd.movies.playback.api.EngineError
import ru.stenfredd.movies.playback.api.EngineEvent
import ru.stenfredd.movies.playback.api.EngineState
@@ -53,9 +70,13 @@ import ru.stenfredd.movies.playback.api.embeddedOrdinal
import ru.stenfredd.movies.playback.api.videoStream
import timber.log.Timber
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable {
override val id = ExoEngineProvider.ID
/** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */
private val sdrThroughGpu = Build.MANUFACTURER.equals("Xiaomi", ignoreCase = true) &&
(context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_TELEVISION
private val trackSelector = DefaultTrackSelector(context).apply {
parameters = buildUponParameters()
.setTunnelingEnabled(false)
@@ -63,20 +84,51 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
.build()
}
/**
* Full ASS/SSA rendering (styles, positioning, embedded fonts) through libass (ass-media), drawn by an overlay
* view above the video. Without it media3 turns ASS into plain text cues.
*/
private val assHandler = AssHandler(AssRenderType.OVERLAY_OPEN_GL, AssHandlerConfig(cacheSize = ASS_CACHE_MB, maxRenderPixels = 1920 * 1080))
private val assParserFactory = AssSubtitleParserFactory(assHandler)
private val player: ExoPlayer = ExoPlayer.Builder(
context,
DefaultRenderersFactory(context)
.setMediaCodecSelector(::decoderInfos)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
AssRenderersFactory(
assHandler,
DefaultRenderersFactory(context)
.setMediaCodecSelector(::decoderInfos)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
),
)
.setTrackSelector(trackSelector)
.setLoadControl(
DefaultLoadControl.Builder()
.setTargetBufferBytes(bufferBytes())
.setPrioritizeTimeOverSizeThresholds(false)
.build(),
)
.setMediaSourceFactory(
DefaultMediaSourceFactory(context, ExoExtractorsFactory { AssMatroskaExtractor(assParserFactory, assHandler) })
// External ASS files are parsed by this factory directly.
.setSubtitleParserFactory(assParserFactory),
)
.setAudioAttributes(
AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
true,
)
.build()
/**
* The default target (~145 MB for video) is sized for phones with big heaps: this TV gives the app 192 MB
* (512 MB with largeHeap), and once the buffer filled up after a few engine switches GC thrashed, playback
* stuttered and the app died with OutOfMemoryError. A quarter of the heap, at most 64 MB: still ~8 s of a 60 Mbit/s 4K remux, plenty over the LAN.
*/
private fun bufferBytes(): Int {
val heapMb = (Runtime.getRuntime().maxMemory() shr 20).toInt()
return (heapMb / 4).coerceIn(MIN_BUFFER_MB, MAX_BUFFER_MB) shl 20
}
private val _state = MutableStateFlow(EngineState())
override val state = _state.asStateFlow()
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
@@ -121,6 +173,11 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
override fun onPlayerError(error: PlaybackException) = reportError(map(error))
}
private var audioUnderruns = 0
private var rebuffers = 0
/** Buffering right after load/seek is expected, only later ones are counted. */
private var startedAtMs = 0L
private val analytics = object : AnalyticsListener {
override fun onVideoDecoderInitialized(eventTime: AnalyticsListener.EventTime, decoderName: String, initializedTimestampMs: Long, initializationDurationMs: Long) {
_stats.update { it.copy(videoDecoder = decoderName) }
@@ -137,6 +194,24 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
override fun onDroppedVideoFrames(eventTime: AnalyticsListener.EventTime, droppedFrames: Int, elapsedMs: Long) {
_stats.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
}
// Not visible as dropped frames: AC3-in-AVI / MPEG-PS style timing problems show up as these.
override fun onAudioUnderrun(eventTime: AnalyticsListener.EventTime, bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) {
audioUnderruns++
Timber.w("Exo audio underrun #%d (%d ms since last feed)", audioUnderruns, elapsedSinceLastFeedMs)
_stats.update { it.copy(extra = it.extra + ("срывы звука" to audioUnderruns.toString())) }
}
override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) {
Timber.d("Exo bandwidth: %.1f Mbit/s (%d KB in %d ms)", bitrateEstimate / 1e6, totalBytesLoaded / 1024, totalLoadTimeMs)
}
override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) {
Timber.w(audioSinkError, "Exo audio sink error")
}
override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
if (state != Player.STATE_BUFFERING || eventTime.realtimeMs - startedAtMs < 3000) return
rebuffers++
Timber.w("Exo rebuffering #%d at %d ms", rebuffers, eventTime.currentPlaybackPositionMs)
_stats.update { it.copy(extra = it.extra + ("буферизаций" to rebuffers.toString())) }
}
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"
@@ -146,17 +221,61 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
}
}
init {
player.addListener(listener)
player.addAnalyticsListener(analytics)
applyVideoPath()
}
/**
* Chosen in the player menu or by a variant key. Per engine: a new playback starts in AUTO, within one playback
* PlaybackSession carries it over to the next engine instance (fallback, restart).
*/
private var pathMode = VideoPathSwitchable.Mode.AUTO
/**
* Default set by [ExoEngineProvider.create] (on if the display supports Dolby Vision); debug variants 1/4 override it.
* Carried over like [pathMode]. With it the TV does switch to Dolby Vision (DispService "Hdr Dolby").
*/
override var useDolbyVisionDecoder = false
override val videoPathMode get() = pathMode
override var usingGpuPath = false
private set
private var pathApplied = false
private val mainHandler = Handler(Looper.getMainLooper())
private var lastRendered = -1
private var lastSkipped = 0
init {
assHandler.init(player)
// Inside the aspect frame: libass lays out relative to the picture, not to the letterboxed screen.
aspectFrame.addView(AssSubtitleView(context, assHandler), FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
player.addListener(listener)
player.addAnalyticsListener(analytics)
applyVideoPath()
mainHandler.postDelayed(::sampleFrameRate, FRAME_RATE_SAMPLE_MS)
}
/**
* Frames actually shown per second, from the decoder counters. "Dropped" only counts frames that were too late;
* a stream the decoder skips or never outputs still plays at a moving position with 0 dropped
* (a DVD rip: ~1 fps on screen, 0 dropped).
*/
private fun sampleFrameRate() {
val counters = player.videoDecoderCounters
if (counters != null && player.isPlaying) {
counters.ensureUpdated()
val rendered = counters.renderedOutputBufferCount
if (lastRendered >= 0) {
val fps = (rendered - lastRendered) * 1000f / FRAME_RATE_SAMPLE_MS
val skipped = counters.skippedOutputBufferCount + counters.skippedInputBufferCount
Timber.d("Exo video: %.1f fps shown, skipped %d, dropped %d", fps, skipped - lastSkipped, counters.droppedBufferCount)
_stats.update { it.copy(extra = it.extra + ("кадров/с на экране" to "%.1f".format(fps)) + ("пропущено декодером" to skipped.toString())) }
lastSkipped = skipped
}
lastRendered = rendered
} else {
lastRendered = -1
}
mainHandler.postDelayed(::sampleFrameRate, FRAME_RATE_SAMPLE_MS)
}
override fun setVideoPathMode(mode: VideoPathSwitchable.Mode) {
pathMode = mode
applyVideoPath()
@@ -166,16 +285,28 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
* 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).
* path is SDR only), >1080p (the UI surface is 1080p, the GPU path would throw away resolution) and interlaced
* video (only the video plane deinterlaces; through the GPU the fields show as combing). Also frame rates that
* don't divide 60 Hz (23.976/24/25/50): the video plane switches the panel to 100/120 Hz, the GPU path stays at
* 60 Hz and judders (3:2). The user preferred the smooth video plane (2026-10-13). Other devices (phones, TVs
* without such post-processing) always use the video plane: it is cheaper and keeps the panel's own refresh rate.
*/
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
VideoPathSwitchable.Mode.AUTO -> sdrThroughGpu && request?.mediaSource?.videoStream?.let { v ->
v.videoRange != VideoRange.HDR && !v.isInterlaced && (v.width ?: 0) <= 1920 && (v.height ?: 0) <= 1088 &&
fitsUiRefreshRate(v.realFrameRate ?: v.averageFrameRate)
} == true
}
/** 30/60 fps (and 29.97/59.94) show evenly on the 60 Hz UI surface; unknown rate counts as fitting. */
private fun fitsUiRefreshRate(fps: Float?): Boolean {
if (fps == null || fps <= 0f) return true
val cadence = UI_REFRESH_HZ / fps
return cadence >= 0.99f && abs(cadence - cadence.roundToInt()) < 0.02f
}
private fun applyVideoPath() {
val gpu = gpuPathFor(request)
if (pathApplied && gpu == usingGpuPath) return
@@ -189,6 +320,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
Timber.i("Exo video output: %s", label)
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
}
@@ -221,6 +353,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
.setSubtitleConfigurations(subtitles)
.apply { if (request.url.contains(".m3u8")) setMimeType(MimeTypes.APPLICATION_M3U8) }
.build()
startedAtMs = SystemClock.elapsedRealtime()
player.setMediaItem(item, request.startPositionMs)
player.prepare()
}
@@ -230,7 +363,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
* 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) {
if (mimeType == MimeTypes.VIDEO_DOLBY_VISION && !useDolbyVisionDecoder && !requiresDolbyVisionDecoder) {
return emptyList()
}
return MediaCodecUtil.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder)
@@ -240,7 +373,10 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
override fun play() = player.play()
override fun pause() = player.pause()
override fun seekTo(positionMs: Long) = player.seekTo(positionMs)
override fun seekTo(positionMs: Long) {
startedAtMs = SystemClock.elapsedRealtime()
player.seekTo(positionMs)
}
override fun selectAudio(streamIndex: Int) {
val req = request ?: return
@@ -262,10 +398,17 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
}
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
Timber.i(
"Exo subtitle %d -> %s (text tracks: %s)",
streamIndex,
group?.getTrackFormat(0)?.let { "${it.id} ${it.sampleMimeType}" } ?: "не найдена",
textGroups.joinToString { "${it.getTrackFormat(0).id}" },
)
trackSelector.parameters = builder.build()
}
override fun release() {
mainHandler.removeCallbacksAndMessages(null)
detachVideo()
player.removeListener(listener)
player.removeAnalyticsListener(analytics)
@@ -347,9 +490,13 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
}
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:"
private const val MIN_BUFFER_MB = 32
private const val FRAME_RATE_SAMPLE_MS = 2000L
/** Compose UI surface on the TV; the GPU path is shown at this rate. */
private const val UI_REFRESH_HZ = 60f
/** libass glyph/bitmap cache; the default 128 MB of native memory is a lot on a 2 GB TV. */
private const val ASS_CACHE_MB = 32
private const val MAX_BUFFER_MB = 64
}
}

View File

@@ -3,6 +3,7 @@ package ru.stenfredd.movies.playback.exo
import android.content.Context
import android.media.MediaCodecInfo.CodecProfileLevel
import android.view.Display.HdrCapabilities
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.CodecType
import org.jellyfin.sdk.model.api.DeviceProfile
@@ -53,18 +54,26 @@ class ExoEngineProvider @Inject constructor(
}
evaluateRange(v.videoRangeType, v.dvProfile, device, no, losses, notes)
if (v.isInterlaced) losses += Loss.NO_DEINTERLACE
// DVD rips (MPEG-PS): such a rip combs in motion on the TV's video plane even with correct
// timestamps; it is bottom field first, while «Иван Васильевич» (MKV, top field first) is fine. Jellyfin
// doesn't report field order, so this goes by container; VLC (yadif) plays these cleanly (user, 2026-10-13).
if (v.isInterlaced && containers.any { it in PROGRAM_STREAM }) losses += Loss.DVD_COMBING
}
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"
when {
passthrough -> notes += "${a.codec}: passthrough"
mime != null && device.hasAudioDecoder(mime) -> Unit
mime != null && ffmpegDecodes(mime) -> notes += "${a.codec}: ffmpeg"
else -> no += "аудио ${a.codec}"
}
// AC3/DTS in AVI: re-framed by AviAudioFramingExtractor.
}
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}"
}
}
@@ -113,7 +122,7 @@ class ExoEngineProvider @Inject constructor(
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.audioMime(c)?.let { device.hasAudioDecoder(it) || ffmpegDecodes(it) } == 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)
@@ -178,24 +187,33 @@ class ExoEngineProvider @Inject constructor(
}
}
override fun create(device: DeviceCapabilities): PlaybackEngine = ExoEngine(context)
override fun create(device: DeviceCapabilities): PlaybackEngine = ExoEngine(context).apply {
useDolbyVisionDecoder = USE_DOLBY_VISION_DECODER && device.displaySupportsHdr(HdrCapabilities.HDR_TYPE_DOLBY_VISION)
}
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.
* Profiles with a base layer (8.x, 9, 10) go to the DV decoder when the display supports Dolby Vision, otherwise
* media3 plays them as HEVC/AVC/AV1 HDR. On the Xiaomi MT9689 (`c2.mtk.dvhe.st`) the TV switches to Dolby Vision
* and the user rated the picture good (2026-10-13; the earlier "duller" was the TV's DV picture mode).
* Debug variant 1 plays the base layer for comparison. Profile 5 always needs the DV decoder.
*/
const val USE_DOLBY_VISION_DECODER = false
const val USE_DOLBY_VISION_DECODER = true
private const val MAX_BITRATE = 400_000_000
private val PROGRAM_STREAM = setOf("mpeg", "mpg", "vob", "mpegps")
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")
/**
* Jellyfin's media3 ffmpeg extension (TrueHD, DTS, ... without passthrough: phones, soundbars without the
* decoder). [ExoEngine] adds it after the MediaCodec renderers, so hardware decoders and passthrough win.
*/
private fun ffmpegDecodes(mime: String) = FfmpegLibrary.supportsFormat(mime)
private fun dvProfileConstant(profile: Int?): Int? = when (profile) {
4 -> CodecProfileLevel.DolbyVisionProfileDvheDtr
5 -> CodecProfileLevel.DolbyVisionProfileDvheStn

View File

@@ -0,0 +1,169 @@
package ru.stenfredd.movies.playback.exo
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import timber.log.Timber
import java.io.EOFException
import kotlin.math.abs
/**
* Fixes MPEG-2 video timestamps from [androidx.media3.extractor.ts.PsExtractor] (DVD rips).
*
* media3's H262Reader gives a picture the PTS of its PES packet, or, if the packet has none, the previous picture's
* time plus one frame. That is done in decode order, which is wrong with B-frames: in DVD VOBs the P-pictures often
* start mid-packet and get "previous + 40 ms", colliding with the B-pictures that follow (I .28 → P .32, B .32,
* B .36 → P .40 ...), and the decoder outputs duplicate and backwards timestamps.
*
* Worse, PsExtractor passes 0 (not "unset") as the time of a PES packet without a PTS, so in the same VOBs 14 of
* 15 pictures of every GOP came out at time 0: the renderer skipped them as before the start position, and the
* picture ran at ~1.5 fps (one keyframe per GOP) while the position advanced normally.
*
* Every MPEG-2 picture header carries its display index within the GOP (temporal_reference), so such times
* (extrapolated, or more than a second off) are replaced by `keyframe time + (temporal_reference - keyframe's) ×
* frame duration`. Plausible times are kept, which also keeps pulldown material (uneven real PTS) as the file has it.
*/
@UnstableApi
internal class Mpeg2TimestampFixExtractor(private val delegate: Extractor) : Extractor {
private val fixers = mutableListOf<FixingTrackOutput>()
override fun sniff(input: ExtractorInput) = delegate.sniff(input)
override fun read(input: ExtractorInput, seekPosition: PositionHolder) = delegate.read(input, seekPosition)
override fun getUnderlyingImplementation() = delegate
override fun release() = delegate.release()
override fun init(output: ExtractorOutput) = delegate.init(object : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput {
val track = output.track(id, type)
return if (type == C.TRACK_TYPE_VIDEO) FixingTrackOutput(track).also(fixers::add) else track
}
override fun endTracks() = output.endTracks()
override fun seekMap(seekMap: SeekMap) = output.seekMap(seekMap)
})
override fun seek(position: Long, timeUs: Long) {
fixers.forEach(FixingTrackOutput::reset)
delegate.seek(position, timeUs)
}
private class FixingTrackOutput(private val out: TrackOutput) : TrackOutput {
private var frameDurationUs = 0L
/** Sample bytes written since the end of the last emitted sample. */
private var pending = ByteArray(256 * 1024)
private var pendingLength = 0
private var previousTimeUs = C.TIME_UNSET
private var anchorTimeUs = C.TIME_UNSET
private var anchorReference = 0
private var fixedCount = 0L
fun reset() {
pendingLength = 0
previousTimeUs = C.TIME_UNSET
anchorTimeUs = C.TIME_UNSET
}
override fun format(format: Format) {
// H262Reader keeps the frame rate to itself; read frame_rate_code from the sequence header (CSD) again.
val code = format.initializationData.firstOrNull()?.takeIf { it.size > 7 }?.let { (it[7].toInt() and 0x0F) - 1 } ?: -1
frameDurationUs = if (format.sampleMimeType == MimeTypes.VIDEO_MPEG2 && code in FRAME_RATES.indices) {
(C.MICROS_PER_SECOND / FRAME_RATES[code]).toLong()
} else {
0
}
out.format(format)
}
override fun durationUs(durationUs: Long) = out.durationUs(durationUs)
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
if (frameDurationUs == 0L) return out.sampleData(input, length, allowEndOfInput, sampleDataPart)
ensureCapacity(length)
val read = input.read(pending, pendingLength, length)
if (read == C.RESULT_END_OF_INPUT) {
if (allowEndOfInput) return read
throw EOFException()
}
out.sampleData(ParsableByteArray(pending.copyOfRange(pendingLength, pendingLength + read)), read, sampleDataPart)
pendingLength += read
return read
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (frameDurationUs != 0L) {
ensureCapacity(length)
System.arraycopy(data.data, data.position, pending, pendingLength, length)
pendingLength += length
}
out.sampleData(data, length, sampleDataPart)
}
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
if (frameDurationUs == 0L) {
out.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
val start = pendingLength - offset - size
val reference = if (start >= 0) temporalReference(start, size) else -1
val extrapolated = previousTimeUs != C.TIME_UNSET && abs(timeUs - (previousTimeUs + frameDurationUs)) <= 1
previousTimeUs = timeUs
var fixedTimeUs = timeUs
when {
reference < 0 -> Unit
flags and C.BUFFER_FLAG_KEY_FRAME != 0 -> {
anchorTimeUs = timeUs
anchorReference = reference
}
anchorTimeUs != C.TIME_UNSET && abs(reference - anchorReference) <= MAX_GOP_FRAMES -> {
val expectedUs = anchorTimeUs + (reference - anchorReference) * frameDurationUs
if (extrapolated || abs(timeUs - expectedUs) > MAX_PTS_DEVIATION_US) fixedTimeUs = expectedUs
if (fixedTimeUs != timeUs && fixedCount++ == 0L) {
Timber.i("MPEG-2: rebuilding broken timestamps from temporal_reference")
}
}
}
// Keep only the bytes written past this sample (they belong to the next one).
if (start >= 0) {
val consumed = start + size
System.arraycopy(pending, consumed, pending, 0, pendingLength - consumed)
pendingLength -= consumed
} else {
pendingLength = 0
}
out.sampleMetadata(fixedTimeUs, flags, size, offset, cryptoData)
}
/** temporal_reference of the first picture header in the sample, or -1. */
private fun temporalReference(start: Int, size: Int): Int {
val end = start + size - 6
var i = start
while (i <= end) {
if (pending[i].toInt() == 0 && pending[i + 1].toInt() == 0 && pending[i + 2].toInt() == 1 && pending[i + 3].toInt() == 0) {
return ((pending[i + 4].toInt() and 0xFF) shl 2) or ((pending[i + 5].toInt() and 0xFF) ushr 6)
}
i++
}
return -1
}
private fun ensureCapacity(extra: Int) {
if (pendingLength + extra > pending.size) pending = pending.copyOf(maxOf(pending.size * 2, pendingLength + extra))
}
private companion object {
/** Larger jumps mean a broken header or a GOP we didn't see start: leave the time alone. */
const val MAX_GOP_FRAMES = 60
/** A real PTS this far from the GOP's own picture order is not a PTS (PsExtractor's 0, see above). */
const val MAX_PTS_DEVIATION_US = 1_000_000L
val FRAME_RATES = doubleArrayOf(24000 / 1001.0, 24.0, 25.0, 30000 / 1001.0, 30.0, 50.0, 60000 / 1001.0, 60.0)
}
}
}

View File

@@ -0,0 +1,292 @@
package ru.stenfredd.movies.playback.exo
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.extractor.ForwardingExtractorInput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.avi.AviExtractor
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
import androidx.media3.extractor.ts.PsExtractor
import ru.stenfredd.movies.playback.exo.ps.DvdPsExtractor
import timber.log.Timber
/**
* [DefaultExtractorsFactory] with fixes: [MatroskaExtractor] wrapped in [SeekHeadChainExtractor], [PsExtractor]
* replaced by [DvdPsExtractor] wrapped in [Mpeg2TimestampFixExtractor], [AviExtractor] wrapped in
* [AviAudioFramingExtractor].
*/
@UnstableApi
internal class ExoExtractorsFactory(
/** Replaces media3's MatroskaExtractor (ASS support), still wrapped in [SeekHeadChainExtractor]. */
private val matroska: (() -> MatroskaExtractor)? = null,
) : ExtractorsFactory {
private val inner = DefaultExtractorsFactory()
override fun createExtractors(): Array<Extractor> = wrap(inner.createExtractors())
override fun createExtractors(uri: Uri, responseHeaders: Map<String, List<String>>): Array<Extractor> =
wrap(inner.createExtractors(uri, responseHeaders))
override fun setSubtitleParserFactory(subtitleParserFactory: SubtitleParser.Factory) =
apply { inner.setSubtitleParserFactory(subtitleParserFactory) }
@Deprecated("Deprecated in media3")
@Suppress("DEPRECATION")
override fun experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled: Boolean) =
apply { inner.experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled) }
override fun experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies: Int) =
apply { inner.experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies) }
override fun setParseHagcMetadata(parseHagcMetadata: Boolean) = apply { inner.setParseHagcMetadata(parseHagcMetadata) }
private fun wrap(extractors: Array<Extractor>): Array<Extractor> =
extractors.map {
when (it) {
is MatroskaExtractor -> SeekHeadChainExtractor(matroska?.invoke() ?: it)
is PsExtractor -> Mpeg2TimestampFixExtractor(DvdPsExtractor())
is AviExtractor -> AviAudioFramingExtractor(it)
else -> it
}
}.toTypedArray()
}
/**
* Lets media3 seek in Matroska files whose first SeekHead only points to a second SeekHead at the end of the file
* (mkvpropedit/MakeMKV), which in turn points to Cues. [MatroskaExtractor] follows one level only, finds no Cues and
* reports the file as unseekable.
*
* Before handing the input over, reads the second SeekHead and then rewrites the first one's entry
* "SeekHead at X" into "Cues at Y" on the fly. Both fields keep their byte length, so nothing else in the file moves.
* Costs two extra range requests, only for such files; everything else passes straight through.
*/
@UnstableApi
internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extractor {
private enum class Phase { PROBE, READ_TAIL, PASS }
private class SeekEntry(val id: Long, val position: Long, val idOffset: Long, val idSize: Int, val positionOffset: Long, val positionSize: Int)
private class Patch(val offset: Long, val bytes: ByteArray)
private var phase = Phase.PROBE
private var segmentStart = 0L
private var chain: SeekEntry? = null
private var patches: List<Patch> = emptyList()
override fun sniff(input: ExtractorInput) = delegate.sniff(input)
override fun init(output: ExtractorOutput) = delegate.init(output)
override fun getUnderlyingImplementation() = delegate
override fun release() = delegate.release()
override fun seek(position: Long, timeUs: Long) {
if (phase != Phase.PASS && position != 0L) phase = Phase.PASS
delegate.seek(position, timeUs)
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
when (phase) {
Phase.PROBE -> {
phase = Phase.PASS
if (input.position == 0L) {
val target = runCatching { probe(input) }.onFailure { Timber.w(it, "MKV SeekHead probe failed") }.getOrNull()
input.resetPeekPosition()
if (target != null) {
phase = Phase.READ_TAIL
seekPosition.position = target
return Extractor.RESULT_SEEK
}
}
}
Phase.READ_TAIL -> {
phase = Phase.PASS
runCatching { readTail(input) }.onFailure { Timber.w(it, "MKV tail SeekHead read failed") }
seekPosition.position = 0
return Extractor.RESULT_SEEK
}
Phase.PASS -> Unit
}
return delegate.read(if (patches.isEmpty()) input else PatchingInput(input, patches), seekPosition)
}
/** Returns the position of the second SeekHead if the first one points only there, without consuming input. */
private fun probe(input: ExtractorInput): Long? {
val r = EbmlReader(input, peek = true)
var e = r.element()
if (e.id != ID_EBML) return null
r.skip(e.size)
e = r.element()
if (e.id != ID_SEGMENT) return null
segmentStart = r.position
repeat(MAX_ELEMENTS_BEFORE_SEEK_HEAD) {
e = r.element()
when (e.id) {
ID_SEEK_HEAD -> {
val entries = parseSeekHead(r, e.size)
if (entries.any { it.id == ID_CUES }) return null
val link = entries.firstOrNull { it.id == ID_SEEK_HEAD && it.idSize == 4 } ?: return null
chain = link
return segmentStart + link.position
}
ID_CLUSTER -> return null
else -> r.skip(e.size)
}
}
return null
}
private fun readTail(input: ExtractorInput) {
val link = chain ?: return
val r = EbmlReader(input, peek = false)
val e = r.element()
if (e.id != ID_SEEK_HEAD) return
val cues = parseSeekHead(r, e.size).firstOrNull { it.id == ID_CUES } ?: return
val size = link.positionSize
if (size < 8 && cues.position ushr (8 * size) != 0L) return
patches = listOf(
Patch(link.idOffset, byteArrayOf(0x1C, 0x53, 0xBB.toByte(), 0x6B)),
Patch(link.positionOffset, ByteArray(size) { i -> (cues.position ushr (8 * (size - 1 - i))).toByte() }),
)
Timber.i("MKV SeekHead chain: Cues at %d via second SeekHead, first SeekHead patched", segmentStart + cues.position)
}
private fun parseSeekHead(r: EbmlReader, size: Long): List<SeekEntry> {
val entries = mutableListOf<SeekEntry>()
val end = r.position + size
while (r.position < end) {
val seek = r.element()
if (seek.id != ID_SEEK) {
r.skip(seek.size)
continue
}
val seekEnd = r.position + seek.size
var id = -1L
var idOffset = 0L
var idSize = 0
var position = -1L
var positionOffset = 0L
var positionSize = 0
while (r.position < seekEnd) {
val c = r.element()
when {
c.size !in 1..8 -> r.skip(c.size)
c.id == ID_SEEK_ID -> { idOffset = r.position; idSize = c.size.toInt(); id = r.uint(idSize) }
c.id == ID_SEEK_POSITION -> { positionOffset = r.position; positionSize = c.size.toInt(); position = r.uint(positionSize) }
else -> r.skip(c.size)
}
}
if (id >= 0 && position >= 0) entries += SeekEntry(id, position, idOffset, idSize, positionOffset, positionSize)
}
return entries
}
/** Minimal EBML reading over either the peek or the read position of an [ExtractorInput]. */
private class EbmlReader(private val input: ExtractorInput, private val peek: Boolean) {
class Element(val id: Long, val size: Long)
private val buf = ByteArray(8)
val position get() = if (peek) input.peekPosition else input.position
fun element(): Element {
val id = vint(keepMarker = true)
val size = vint(keepMarker = false)
require(size >= 0) { "unknown element size" }
return Element(id, size)
}
fun skip(size: Long) {
require(size in 0..MAX_SKIP) { "element too large to skip: $size" }
if (peek) input.advancePeekPosition(size.toInt()) else input.skipFully(size.toInt())
}
fun uint(size: Int): Long {
fill(size)
return (0 until size).fold(0L) { acc, i -> (acc shl 8) or (buf[i].toLong() and 0xFF) }
}
private fun vint(keepMarker: Boolean): Long {
fill(1)
val first = buf[0].toInt() and 0xFF
val length = Integer.numberOfLeadingZeros(first) - 23
require(length in 1..8) { "bad EBML varint" }
var value = if (keepMarker) first.toLong() else (first and (0xFF ushr length)).toLong()
var allOnes = value == (0xFF ushr length).toLong()
if (length > 1) {
fill(length - 1)
for (i in 0 until length - 1) {
val b = buf[i].toLong() and 0xFF
allOnes = allOnes && b == 0xFFL
value = (value shl 8) or b
}
}
return if (!keepMarker && allOnes) -1 else value
}
private fun fill(n: Int) {
if (peek) input.peekFully(buf, 0, n) else input.readFully(buf, 0, n)
}
}
/** Replaces bytes at absolute file offsets in everything read or peeked through it. */
private class PatchingInput(input: ExtractorInput, private val patches: List<Patch>) : ForwardingExtractorInput(input) {
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
val at = position
return super.read(buffer, offset, length).also { if (it > 0) apply(at, buffer, offset, it) }
}
override fun readFully(target: ByteArray, offset: Int, length: Int, allowEndOfInput: Boolean): Boolean {
val at = position
return super.readFully(target, offset, length, allowEndOfInput).also { if (it) apply(at, target, offset, length) }
}
override fun readFully(target: ByteArray, offset: Int, length: Int) {
val at = position
super.readFully(target, offset, length)
apply(at, target, offset, length)
}
override fun peek(target: ByteArray, offset: Int, length: Int): Int {
val at = peekPosition
return super.peek(target, offset, length).also { if (it > 0) apply(at, target, offset, it) }
}
override fun peekFully(target: ByteArray, offset: Int, length: Int, allowEndOfInput: Boolean): Boolean {
val at = peekPosition
return super.peekFully(target, offset, length, allowEndOfInput).also { if (it) apply(at, target, offset, length) }
}
override fun peekFully(target: ByteArray, offset: Int, length: Int) {
val at = peekPosition
super.peekFully(target, offset, length)
apply(at, target, offset, length)
}
private fun apply(at: Long, buffer: ByteArray, offset: Int, length: Int) {
for (p in patches) {
for (i in p.bytes.indices) {
val abs = p.offset + i
if (abs >= at && abs < at + length) buffer[offset + (abs - at).toInt()] = p.bytes[i]
}
}
}
}
private companion object {
const val ID_EBML = 0x1A45DFA3L
const val ID_SEGMENT = 0x18538067L
const val ID_SEEK_HEAD = 0x114D9B74L
const val ID_SEEK = 0x4DBBL
const val ID_SEEK_ID = 0x53ABL
const val ID_SEEK_POSITION = 0x53ACL
const val ID_CUES = 0x1C53BB6BL
const val ID_CLUSTER = 0x1F43B675L
/** The SeekHead is normally the first child of the Segment; don't buffer half a file looking for it. */
const val MAX_ELEMENTS_BEFORE_SEEK_HEAD = 8
const val MAX_SKIP = 1L shl 20
}
}

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