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