From eaccef33dc131942e96aa2dde61b1d95e6f54d10 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:42:04 +0200 Subject: [PATCH] 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 --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 13 ++--- .../com/edde746/plezy/libass/AssRender.kt | 3 ++ .../media/widget/AssSubtitleAtlasPipeline.kt | 52 +++++++++++++++++-- .../media/widget/AssSubtitleSurfaceView.kt | 3 +- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index ab3e87ad8..51c1945bd 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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() diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/AssRender.kt b/android/libass/src/main/java/com/edde746/plezy/libass/AssRender.kt index 6000bc55e..aca19a2ea 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/AssRender.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/AssRender.kt @@ -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) } diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt index 52b2e783a..f867feaf7 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt @@ -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 diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleSurfaceView.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleSurfaceView.kt index 4e6420e6a..f0ef31a6c 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleSurfaceView.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleSurfaceView.kt @@ -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() }