fix(player): preserve transcoded subtitles at high speed

close #1622
This commit is contained in:
edde746
2026-07-25 04:21:37 +02:00
parent 0643787fbe
commit 2b3853a882
21 changed files with 1524 additions and 445 deletions
@@ -55,6 +55,9 @@ import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.FilteringMediaSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
@@ -308,6 +311,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// External subtitles added dynamically
private val externalSubtitles = mutableListOf<MediaItem.SubtitleConfiguration>()
private val externalSubtitleUris = mutableListOf<String>()
private val externalSubtitleContainerUris = mutableListOf<String>()
private var playbackMediaSourceFactory: DefaultMediaSourceFactory? = null
private var currentMediaUri: String? = null
private var currentHeaders: Map<String, String>? = null
private var currentMediaIsLive: Boolean = false
@@ -660,6 +665,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
.setSubtitleParserFactory(assParserFactory)
playbackMediaSourceFactory = mediaSourceFactory
// Wrap text renderers with subtitle delay support
val wrappedRenderersFactory = RenderersFactory { eventHandler, videoListener, audioListener, textOutput, metadataOutput ->
@@ -1215,7 +1221,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"lastOutput=${describeAudioTrackConfig(previousAudioTrackConfig)}, actions=$lastAudioRecoveryAction"
)
player.setMediaItem(buildMediaItem(uri), savedPosition)
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
return true
@@ -1626,6 +1632,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val isExternal = format.id?.startsWith("external_") == true
val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null
val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] }
val isContainer = !isExternal && externalSubtitleContainerUris.isNotEmpty()
val containerUri = if (isContainer) externalSubtitleContainerUris.first() else null
Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")
@@ -1638,8 +1646,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0),
"forced" to (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0),
"selected" to isSelected,
"external" to isExternal,
"external-filename" to externalUri
"external" to (isExternal || isContainer),
"container" to isContainer,
"external-filename" to (externalUri ?: containerUri)
)
trackList.add(track)
@@ -2337,6 +2346,34 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
return mediaItemBuilder.build()
}
private fun buildPlaybackMediaSource(uri: String): MediaSource? {
val factory = playbackMediaSourceFactory ?: return null
val primarySource = factory.createMediaSource(buildMediaItem(uri))
if (externalSubtitleContainerUris.isEmpty()) return primarySource
val sources = mutableListOf<MediaSource>(primarySource)
externalSubtitleContainerUris.forEach { containerUri ->
val containerSource = factory.createMediaSource(MediaItem.fromUri(containerUri))
sources.add(FilteringMediaSource(containerSource, C.TRACK_TYPE_TEXT))
}
return MergingMediaSource(
/* adjustPeriodTimeOffsets = */
true,
/* clipDurations = */
false,
*sources.toTypedArray()
)
}
private fun setCurrentMediaSource(player: ExoPlayer, uri: String, positionMs: Long) {
val mediaSource = buildPlaybackMediaSource(uri)
if (mediaSource == null) {
player.setMediaItem(buildMediaItem(uri), positionMs)
} else {
player.setMediaSource(mediaSource, positionMs)
}
}
private fun selectedAudioFormat(): Format? {
val player = exoPlayer ?: return null
val selectedAudioGroup = player.currentTracks.groups.firstOrNull {
@@ -2887,6 +2924,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
externalSubtitles.clear()
externalSubtitleUris.clear()
externalSubtitleContainerUris.clear()
lastSubtitleCues = emptyList()
hadSelectedTextTrack = false
audioTrackGroupMap.clear()
@@ -2895,9 +2933,20 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
selectedSubtitleTrackId = null
pendingDvTrackRestore = null
// Build external subtitle configurations (attached to MediaItem before prepare)
externalSubtitleList?.forEachIndexed { index, sub ->
val subUri = sub["uri"] as? String ?: return@forEachIndexed
// Build external subtitle sources before prepare. Container sidecars are
// filtered to text tracks and merged with the primary source; standalone
// subtitle files continue to use MediaItem subtitle configurations.
externalSubtitleList?.forEach { sub ->
val subUri = sub["uri"] as? String ?: return@forEach
if (subUri.isBlank()) return@forEach
if (sub["isContainer"] as? Boolean == true) {
if (!externalSubtitleContainerUris.contains(subUri)) {
externalSubtitleContainerUris.add(subUri)
}
return@forEach
}
val index = externalSubtitles.size
val title = sub["title"] as? String
val language = sub["language"] as? String
val codec = sub["codec"] as? String
@@ -2940,10 +2989,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
)
emitSeekable(false, force = true)
val mediaItem = buildMediaItem(uri)
exoPlayer?.apply {
setMediaItem(mediaItem, startPositionMs)
setCurrentMediaSource(this, uri, startPositionMs)
prepare()
playWhenReady = autoPlay
}
@@ -3174,8 +3221,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
clearTextOverrides = true
)
val mediaItem = buildMediaItem(uri)
player.setMediaItem(mediaItem, savedPosition)
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
emitLog("info", "dv-debug", "Reloaded media for DV mode $dvMode at ${savedPosition}ms")
@@ -3317,8 +3363,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
)
selectedSubtitleTrackId = null
val mediaItem = buildMediaItem(mediaUri)
player.setMediaItem(mediaItem, savedPosition)
setCurrentMediaSource(player, mediaUri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
} else {
@@ -3748,6 +3793,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
trackSelector = null
httpDataSourceFactory = null
dataSourceFactory = null
playbackMediaSourceFactory = null
assHandler?.release()
assHandler = null
@@ -1258,6 +1258,7 @@ class ExoPlayerPlugin :
val escapedUris = externalSubtitles.orEmpty()
.mapNotNull { it["uri"] as? String }
.filter { it.isNotEmpty() }
.distinct()
.map(::escapeMpvPathListEntry)
.toList()
@@ -585,6 +585,31 @@ class ExoPlayerPluginTest {
core.dispose()
}
@Test
fun mpvFallbackLoadsSharedSubtitleContainerOnce() {
val plugin = ExoPlayerPlugin()
val options = mutableListOf<String>()
val appendOptions = plugin.javaClass.getDeclaredMethod(
"appendExternalSubtitleOptions",
MutableList::class.java,
List::class.java
).apply {
isAccessible = true
}
appendOptions.invoke(
plugin,
options,
listOf(
mapOf("uri" to "shared.mkv", "isContainer" to true),
mapOf("uri" to "shared.mkv", "isContainer" to true),
mapOf("uri" to "")
)
)
assertEquals(listOf("sub-files=%10%shared.mkv"), options)
}
@Test
fun configDetachAndEngineDetachReleaseExoActivityOwnershipExactlyOnce() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()