fix(subtitles): clear a displayed ASS cue immediately when subtitles are disabled

Turning subtitles off (or hiding them) while an ASS/SSA cue was on screen left
that cue painted until its natural end time on the ExoPlayer backend: AssHandler
nulls the libass track, after which every render returns null, no payload ever
reaches the GL thread again, and the overlay keeps its last swapped atlas. The
existing invalidateSubtitles() call from the #1387 fix could never repaint it.

The atlas pipeline now detects a trackless render request and hands the GL
thread one zero-quad payload, which clears and swaps a transparent frame. The
clear is keyed on the renderer state generation so one dropped as stale is
retried, and it self-heals from per-video-frame requests while playing; the
existing invalidate on the disable transition covers the paused case.

close #1884
This commit is contained in:
edde746
2026-08-16 12:42:04 +02:00
parent 2ff3b350ae
commit eaccef33dc
4 changed files with 61 additions and 10 deletions
@@ -1253,12 +1253,13 @@ class ExoPlayerCore(private val activity: Activity) :
}
}
// Disabling the text track produces no trailing empty CueGroup, and no new
// video frame re-renders the libass overlay while paused, so the last SRT/VTT
// cue stays painted on the SubtitleViews and the last ASS frame stays on the
// overlay. AssHandler (registered before this listener) has already nulled the
// libass track by now, so re-rendering the last position clears it. Gate on the
// transition to avoid redundant clears on every track change. (#1387)
// Disabling the text track produces no trailing empty CueGroup, so the last
// SRT/VTT cue stays painted on the SubtitleViews. AssHandler (registered
// before this listener) has already nulled the libass track by now, so the
// invalidate pushes one request through the atlas pipeline, which swaps an
// explicit blank frame for a trackless render — required while paused, where
// no video frame triggers one. Gate on the transition to avoid redundant
// clears on every track change. (#1387, #1884)
val hasSelectedText = hasSelectedTextTrack(tracks)
if (!hasSelectedText && hadSelectedTextTrack) {
lastSubtitleCues = emptyList()
@@ -89,6 +89,9 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
lock.withLock { this.track = track }
}
/** False after [setTrack] with null (subtitles disabled) — renders would return null. */
val hasTrack: Boolean get() = lock.withLock { track != null }
fun setFontScale(scale: Float) {
generation.incrementAndGet()
withNative { nativeAssRenderSetFontScale(it, scale) }
@@ -313,8 +313,9 @@ internal class AssAtlasPipeline(
/**
* Re-renders the last requested position — for renderer state changes (margins,
* use-margins) that must become visible while playback is paused. Safe during
* playback: the next video frame's [requestRender] supersedes it (latest-wins).
* use-margins, a disabled track) that must become visible while playback is
* paused. Safe during playback: the next video frame's [requestRender]
* supersedes it (latest-wins).
*/
fun invalidate() {
libassThread.invalidate()
@@ -582,6 +583,12 @@ private class AtlasLibassThread(
@Volatile private var lastRequestedPtsUs = UNSET
private var contentSeqCounter = 0L
/** True once any payload reached the GL thread — the overlay may be showing content. */
private var hasEverPosted = false
/** State generation a trackless blank clear was posted for; MIN_VALUE = none pending. */
private var tracklessClearedGeneration = Long.MIN_VALUE
// Thread-confined; created on first render so non-ASS playback never allocates.
private var engine: SpecRenderEngine? = null
private var engineStateGeneration = Long.MIN_VALUE
@@ -760,7 +767,22 @@ private class AtlasLibassThread(
val waitMs = (tDrain - request.enqueueNs) / 1_000_000
// Before any ASS render exists (SRT/VTT or no subs) do nothing — this also
// keeps the slot buffers unallocated for non-ASS playback.
if (assHandler.render == null) return
val render = assHandler.render ?: return
if (!render.hasTrack) {
// Subtitles were disabled mid-cue: every render now returns null, so no
// payload would ever reach the GL thread again and the last swapped cue
// would sit on the overlay until playback ends (#1884). Swap one explicit
// blank frame instead. Keyed on the state generation ([AssRender.setTrack]
// bumped it) so a clear the GL thread drops as stale is retried by the
// next request; reset below once a track is live again.
val tracklessGeneration = stateGeneration()
if (hasEverPosted && tracklessClearedGeneration != tracklessGeneration) {
tracklessClearedGeneration = tracklessGeneration
postBlankClear(request, tracklessGeneration)
}
return
}
tracklessClearedGeneration = Long.MIN_VALUE
val slots = acquireSlots()
val generation = stateGeneration()
val engine = ensureEngine(slots, generation)
@@ -790,6 +812,7 @@ private class AtlasLibassThread(
payload.requestSeq = request.sequence
payload.stateGeneration = generation
onFrameReady(payload)
hasEverPosted = true
if (AssAtlasPipelineConfig.TIMING_LOGS) {
Log.d(
TAG,
@@ -828,6 +851,29 @@ private class AtlasLibassThread(
maybePrefetch(engine, slots, pts, pinned)
}
/**
* Hands the GL thread a zero-quad payload — [AtlasRenderer.onDrawFrame] clears
* and draws nothing — so a cue still on screen when its track is disabled goes
* away immediately instead of outliving the selection (#1884).
*/
private fun postBlankClear(request: PendingFrame, generation: Long) {
val slots = acquireSlots()
// Never rewrite the slot the GL thread may still be reading (slotCount >= 2).
val payload = slots.payloads.first { it.slotIndex != glTakenSlot() }
payload.frame = AssAtlasFrame(0, 0, 0, 0, 0)
payload.contentSeq = ++contentSeqCounter
payload.sourcePresentationTimeUs = request.sourcePtsUs
payload.presentationTimeUs = request.ptsUs
payload.releaseTimeNs = request.releaseNs
payload.phaseLeadUs = request.phaseLeadUs
payload.requestSeq = request.sequence
payload.stateGeneration = generation
onFrameReady(payload)
if (AssAtlasPipelineConfig.TIMING_LOGS) {
Log.d(TAG, "trackless-clear req=${request.sequence} pts=${request.ptsUs / 1000}ms gen=$generation")
}
}
/** Start time of the last event boundary we cache-warmed (libass/track ms). */
private var lastPrefetchedStartMs = Long.MIN_VALUE
@@ -45,7 +45,8 @@ class AssSubtitleSurfaceView(
pipeline?.requestRender(presentationTimeUs, releaseTimeNs)
}
/** Re-renders the last position, e.g. after margin changes while paused. */
/** Re-renders the last position — margin changes while paused, or clearing a
* still-visible cue right after subtitles were disabled (#1884). */
fun invalidateSubtitles() {
pipeline?.invalidate()
}