fix(video): centre spinner never appeared on the one stall users actually see

Reported: "the loading buffer is not the spinner, it still loads at the left of
the timer." Correct — and it was our bug, not a cosmetic preference.

The spinner was driven by a hand-picked event list (waiting/seeking/stalled). But
a cold start from preload="none" NEVER fires `waiting`: it runs
loadstart -> loadedmetadata -> loadeddata -> canplay -> playing straight through.
Since the bandwidth fix, that cold start is the only stall left — so the spinner
sat out the exact moment it existed for, leaving just the OS control bar's own
small indicator where the play button sits, i.e. left of the timer.

Now the spinner is derived from the element's real state rather than guessed from
events: busy = seeking || (!paused && !ended && readyState < HAVE_FUTURE_DATA),
recomputed on every relevant media event. Simulated against the real event
sequences before shipping — cold start, mid-stream stall and seek all spin;
paused/ended/idle never do.

Also dim the frame to 72% brightness while buffering so the spinner reads
instantly against a bright poster, and give it a dark backing disc.

Note: the small indicator inside the native control bar belongs to the OS's own
video controls and cannot be suppressed while we use them. Ours is now the loud,
central one; removing the OS indicator entirely would mean custom controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 11:48:11 +05:30
parent 8a5409987c
commit 310f37f29b
+19 -5
View File
@@ -666,8 +666,10 @@
small/unclear. The spinner is display-only (pointer-events:none) so it never blocks the controls. */
.att-vid{position:relative;width:min(260px,74vw);max-height:340px;border-radius:10px;overflow:hidden;display:block;margin:.15rem 0;background:#000;line-height:0;}
.att-vid-v{display:block;width:100%;max-height:340px;background:#000;object-fit:contain;}
.att-vid-spin{position:absolute;top:50%;left:50%;width:46px;height:46px;margin:-23px 0 0 -23px;border-radius:50%;border:3px solid rgba(255,255,255,.35);border-top-color:#fff;opacity:0;pointer-events:none;z-index:3;}
.att-vid-spin{position:absolute;top:50%;left:50%;width:46px;height:46px;margin:-23px 0 0 -23px;border-radius:50%;border:3px solid rgba(255,255,255,.35);border-top-color:#fff;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;z-index:3;}
.att-vid.buffering .att-vid-spin{opacity:1;animation:ptrspin .8s linear infinite;}
/* Dim the frame while buffering so the centre spinner reads instantly against a bright poster. */
.att-vid.buffering .att-vid-v{filter:brightness(.72);}
.bubble.mine .att-file{background:rgba(255,255,255,.18);border-color:rgba(255,255,255,.3);}
.att-file .att-sz{opacity:.65;font-size:.75rem;flex:0 0 auto;}
/* groups */
@@ -1165,7 +1167,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-23-batch160';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-23-batch161';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
//
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -5045,9 +5047,21 @@ window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(
// is small/unclear). Media events (waiting/playing/…) do NOT bubble, so we listen in the CAPTURE phase. This
// only toggles a class — it never intercepts taps, so the native controls keep working.
(function(){
const set=(on)=>(e)=>{ const v=e.target; if(v && v.classList && v.classList.contains('att-vid-v')){ const w=v.parentElement; if(w && w.classList.contains('att-vid')) w.classList.toggle('buffering', on); } };
['waiting','seeking','stalled'].forEach(ev=>document.addEventListener(ev, set(true), true));
['playing','seeked','pause','ended','error'].forEach(ev=>document.addEventListener(ev, set(false), true)); // NOT canplay/loadeddata — they fire mid-buffer and would hide the spinner too early
const HAVE_FUTURE_DATA=3; // below this the element cannot keep playing = it is buffering
// Derive the spinner from the element's REAL state rather than a hand-picked event list. A cold start
// from preload="none" never fires `waiting` (it runs loadstart→loadedmetadata→canplay→playing straight
// through), so an event-list approach missed the one stall the user actually sees — the wait after tapping
// play — and left only the OS controls' own tiny indicator next to the timer.
function sync(v){
if(!v || !v.classList || !v.classList.contains('att-vid-v')) return;
const w=v.parentElement; if(!w || !w.classList.contains('att-vid')) return;
const busy = v.seeking || (!v.paused && !v.ended && v.readyState < HAVE_FUTURE_DATA);
w.classList.toggle('buffering', !!busy);
}
const onEv=(e)=>sync(e.target);
['loadstart','loadedmetadata','loadeddata','canplay','canplaythrough','play','playing','waiting','stalled',
'suspend','progress','timeupdate','seeking','seeked','pause','ended','error','emptied']
.forEach(ev=>document.addEventListener(ev,onEv,true)); // media events don't bubble → capture phase
})();
// #9: swipe a message bubble to the right to reply to it (mobile), like WhatsApp/Teams. The bubble
// follows the finger a little; releasing past the threshold opens the reply composer for that message.