Playback fixes: stall watchdog, DVD and MPEG-2, LAN streaming, Dolby Vision
This commit is contained in:
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user