mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
fix(transition-engine): Android supersede wedge/leak + web/Tizen stale-video guard (#205)
Pre-release review follow-up to #204: fixes the Android superseded-wipe playlist wedge + GL leak, and adds the stale-item guard to web/Tizen renderVideoBuffered.
This commit is contained in:
parent
96b71a0d56
commit
ba00dd2811
|
|
@ -99,15 +99,12 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
|
|||
visibility = GONE
|
||||
}
|
||||
|
||||
/** Main-thread entry: run a wipe. If the runtime can't start it, onDone still fires (hard cut). */
|
||||
/** Main-thread entry: run a wipe. If the runtime can't start it, onDone still fires (hard cut).
|
||||
* onDone is the RAW content swap — the overlay's visibility/render-mode are owned by the renderer
|
||||
* (parkIfIdle), NOT by this callback, so a superseded wipe's late onDone can't hide the overlay out
|
||||
* from under a newer wipe that's already in flight (that was a playlist-wedge bug). */
|
||||
fun play(from: Bitmap, to: Bitmap, fragmentSrc: String, params: Map<String, Float>, durationMs: Int, onDone: () -> Unit) {
|
||||
val job = Job(from, to, fragmentSrc, params, durationMs.coerceAtLeast(1)) {
|
||||
// wrap so the view is hidden + parked on the main thread right when the swap happens
|
||||
visibility = GONE
|
||||
renderMode = RENDERMODE_WHEN_DIRTY
|
||||
onDone()
|
||||
}
|
||||
incoming = job
|
||||
incoming = Job(from, to, fragmentSrc, params, durationMs.coerceAtLeast(1), onDone)
|
||||
visibility = VISIBLE
|
||||
renderMode = RENDERMODE_CONTINUOUSLY
|
||||
requestRender()
|
||||
|
|
@ -129,16 +126,17 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
|
|||
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
|
||||
GLES20.glClearColor(0f, 0f, 0f, 0f) // transparent: content behind shows through pre-wipe
|
||||
vShader = compile(GLES20.GL_VERTEX_SHADER, TransitionGlsl.VERTEX)
|
||||
// a context (re)create drops any active job's GL objects — abandon it, the caller already
|
||||
// swapped or will on the next advance; never leave the overlay stuck visible.
|
||||
active?.let { finishOnMain(it) }
|
||||
// a context (re)create drops any active job's GL objects — abandon it (hard-cut to its target),
|
||||
// and park if nothing new is queued so the overlay never sticks visible.
|
||||
active?.let { swapOnMain(it) }
|
||||
active = null; program = 0; texFrom = 0; texTo = 0
|
||||
parkIfIdle()
|
||||
}
|
||||
|
||||
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) { vw = width; vh = height; GLES20.glViewport(0, 0, width, height) }
|
||||
|
||||
override fun onDrawFrame(gl: GL10?) {
|
||||
incoming?.let { j -> incoming = null; active?.let { finishOnMain(it) }; setup(j) } // pick up a new request
|
||||
incoming?.let { j -> incoming = null; active?.let { supersede(it) }; setup(j) } // pick up a new request
|
||||
val j = active
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||
if (j == null) return
|
||||
|
|
@ -172,6 +170,9 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
|
|||
for (name in j.params.keys) uParam[name] = GLES20.glGetUniformLocation(prog, name)
|
||||
texFrom = uploadTexture(j.from)
|
||||
texTo = uploadTexture(j.to)
|
||||
// pixels now live in the GL textures — free the (full-screen ARGB) fit bitmaps promptly
|
||||
// rather than waiting on GC, which matters on a long-lived signage device.
|
||||
try { j.from.recycle(); j.to.recycle() } catch (_: Throwable) {}
|
||||
} catch (e: Throwable) {
|
||||
Log.w("TransitionGL", "wipe setup failed, hard-cutting: ${e.message}")
|
||||
j.failed = true
|
||||
|
|
@ -193,14 +194,30 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
|
|||
GLES20.glDisableVertexAttribArray(0)
|
||||
}
|
||||
|
||||
// Release GL objects, then run the job's onDone on the main thread (the content swap happens there).
|
||||
// Wipe completed (or failed): release its GL, run the content swap on the main thread, then park
|
||||
// the overlay IF no newer wipe is queued.
|
||||
private fun finish(j: Job) {
|
||||
active = null
|
||||
releaseGl()
|
||||
finishOnMain(j)
|
||||
swapOnMain(j)
|
||||
parkIfIdle()
|
||||
}
|
||||
|
||||
private fun finishOnMain(j: Job) { post { j.onDone() } } // View.post -> main thread; guarded once by active handoff
|
||||
// Superseded by a newer wipe (advance fired mid-wipe): release THIS wipe's GL (else its program +
|
||||
// both textures leak) and run its content swap, but do NOT park — the new wipe keeps the overlay
|
||||
// visible + rendering. setup(new) runs immediately after and rebuilds the GL objects.
|
||||
private fun supersede(old: Job) {
|
||||
releaseGl()
|
||||
swapOnMain(old)
|
||||
}
|
||||
|
||||
private fun swapOnMain(j: Job) { post { j.onDone() } } // View.post -> main thread (the content swap)
|
||||
|
||||
// Hide the overlay + stop the render loop, but ONLY if nothing new is queued. `incoming` is
|
||||
// @Volatile and mutated only on the main thread (play), the same thread this posted block runs on,
|
||||
// so a newer play() either already set incoming (we skip park) or runs after (it re-shows) —
|
||||
// race-free, and never leaves a new wipe hidden.
|
||||
private fun parkIfIdle() { post { if (incoming == null) { visibility = GONE; renderMode = RENDERMODE_WHEN_DIRTY } } }
|
||||
|
||||
private fun releaseGl() {
|
||||
if (texFrom != 0) { GLES20.glDeleteTextures(1, intArrayOf(texFrom), 0); texFrom = 0 }
|
||||
|
|
|
|||
|
|
@ -2381,10 +2381,17 @@
|
|||
// frame as the wipe's `to`, run the GL wipe, then mount + resume the real <video> FROM that same
|
||||
// frame (zero jump). Every failure path (no from-frame, un-decodable, no runtime, context loss, or
|
||||
// the decode watchdog) hard-cuts straight to mount+play — never a blank.
|
||||
// Bumped on every renderContent dispatch. A buffered render captures the value at start and bails if
|
||||
// it changes — the warm-play is async (first-frame wait + the wipe), and a playlist push mid-window
|
||||
// must not let a stale clip tear down the newer content that already took over.
|
||||
let renderSeq = 0;
|
||||
function renderVideoBuffered(item) {
|
||||
const src = item.remote_url || `${config.serverUrl}/uploads/content/${item.filepath}`;
|
||||
const from = currentTexturableFrame(); // capture the outgoing frame NOW, before any teardown
|
||||
const t = item.transition;
|
||||
const mySeq = renderSeq;
|
||||
const stale = () => renderSeq !== mySeq; // a newer renderContent superseded this one
|
||||
const abandon = () => { try { video.pause(); video.removeAttribute('src'); video.load(); } catch (e) {} };
|
||||
const video = document.createElement('video');
|
||||
video.crossOrigin = 'anonymous'; // texturable (CORS-clean) + matches the legacy branch
|
||||
video.playsInline = true;
|
||||
|
|
@ -2398,6 +2405,7 @@
|
|||
// unconditionally releases the old currentVideoEl). Sets the REAL mute state here (warm-play was
|
||||
// muted) and resumes from the snapshot frame, so there's no forward jump on reveal.
|
||||
const mountVideo = () => {
|
||||
if (stale()) { abandon(); return; } // a newer item took over during the wipe — don't clobber it
|
||||
const c = document.getElementById('playerContainer');
|
||||
teardownCurrentMedia(); // drop the outgoing frame (new video still detached)
|
||||
c.style.display = 'block';
|
||||
|
|
@ -2414,6 +2422,7 @@
|
|||
const onFirstFrame = () => {
|
||||
if (done) return; done = true;
|
||||
if (watchdog) clearTimeout(watchdog);
|
||||
if (stale()) { abandon(); return; } // superseded before the wipe even started
|
||||
try { video.pause(); } catch (e) {} // hold at the snapshot frame; mountVideo resumes from here
|
||||
const canTexture = video.readyState >= 2 && video.videoWidth > 0 && isMediaReadable(video);
|
||||
let to = null;
|
||||
|
|
@ -2447,12 +2456,14 @@
|
|||
});
|
||||
// Watchdog caps the wait so a cold/slow clip hard-cuts (mount+play) instead of the boundary hanging
|
||||
// (mirrors renderImageBuffered's watchdog).
|
||||
watchdog = setTimeout(() => { if (done) return; done = true; mountVideo(); }, 800);
|
||||
watchdog = setTimeout(() => { if (done) return; done = true; if (stale()) { abandon(); return; } mountVideo(); }, 800);
|
||||
video.src = src;
|
||||
video.load();
|
||||
}
|
||||
|
||||
function renderContent(item) {
|
||||
// New dispatch supersedes any in-flight buffered (async warm-play) render — see renderSeq.
|
||||
renderSeq++;
|
||||
// Cancel any pending advance/refresh timer up front so a prior item's timer (incl. a
|
||||
// self-rescheduling widget refresh) can't fire against the new content.
|
||||
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
|
||||
|
|
|
|||
|
|
@ -603,6 +603,12 @@ PlaylistPlayer.prototype.renderVideoBuffered = function (item, single) {
|
|||
var self = this, stage = this.stage;
|
||||
var from = this._texturableStageFrame(); // capture the outgoing frame NOW (playCurrent skipped clearStage)
|
||||
var t = item.transition;
|
||||
// The warm-play (first-frame wait + wipe) is async; if the current item changes mid-window (playlist
|
||||
// push / advance), a stale clip must NOT clear the stage + mount over the newer content. Mirrors the
|
||||
// stale() guard renderImage already uses.
|
||||
var targetIdx = this.index;
|
||||
var stale = function () { return self.index !== targetIdx || self.items[targetIdx] !== item; };
|
||||
var abandon = function () { try { v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} };
|
||||
var v = document.createElement('video');
|
||||
this.fit(v, item);
|
||||
v.muted = true; v.setAttribute('playsinline', ''); // warm-play MUST be muted (autoplay policy)
|
||||
|
|
@ -610,6 +616,7 @@ PlaylistPlayer.prototype.renderVideoBuffered = function (item, single) {
|
|||
v.style.cssText = '';
|
||||
var done = false, watchdog = null;
|
||||
var mountVideo = function () { // full clear + append + resume from the snapshot frame
|
||||
if (stale()) { abandon(); return; } // a newer item took over during the wipe — don't clobber it
|
||||
self.clearStage();
|
||||
self.currentVideoEl = v; // wall/group drift-correct this (only after clearStage)
|
||||
stage.appendChild(v);
|
||||
|
|
@ -624,6 +631,7 @@ PlaylistPlayer.prototype.renderVideoBuffered = function (item, single) {
|
|||
var onFirstFrame = function () {
|
||||
if (done) return; done = true;
|
||||
if (watchdog) clearTimeout(watchdog);
|
||||
if (stale()) { abandon(); return; } // superseded before the wipe even started
|
||||
try { v.pause(); } catch (e) {} // hold at the snapshot frame; mountVideo resumes here
|
||||
var w = stage.clientWidth || 1280, h = stage.clientHeight || 720;
|
||||
var to = null;
|
||||
|
|
@ -640,7 +648,7 @@ PlaylistPlayer.prototype.renderVideoBuffered = function (item, single) {
|
|||
};
|
||||
v.addEventListener('loadeddata', function () { var p = v.play(); if (p && p.then) p.then(armFrame).catch(armFrame); else armFrame(); }, { once: true });
|
||||
v.addEventListener('error', function () { if (done) return; done = true; if (watchdog) clearTimeout(watchdog); self.skipSoon(); });
|
||||
watchdog = setTimeout(function () { if (done) return; done = true; mountVideo(); }, 800); // cold/slow clip -> hard cut
|
||||
watchdog = setTimeout(function () { if (done) return; done = true; if (stale()) { abandon(); return; } mountVideo(); }, 800); // cold/slow clip -> hard cut
|
||||
v.src = this.contentUrl(item);
|
||||
v.load();
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue