fix(video): one centred control — download → % → play, as specified

The download button was a small corner overlay, which landed on top of the native
control bar's speaker icon. That was not the design asked for either: it should be
a single control in the CENTRE that turns into a play button once downloaded.

The tile is now the masked poster plus one centred button:
  not downloaded → download icon  (the element has no src at all, so nothing is
                                   fetched until it is tapped)
  downloading    → live % inside that same button, in place
  downloaded     → play icon; tapping plays the LOCAL file

The native control bar is switched on only when playback starts, so there is
nothing for the control to collide with. Progress reports into the button rather
than the floating chip, so a video download no longer shows two indicators.

Also gives the tile a min-height so the thread doesn't jump while the poster loads.
Web/PWA is untouched — it has nowhere to download to, so it keeps streaming with
native controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 19:57:07 +05:30
parent cb81dd6106
commit 82fa8e04eb
+48 -29
View File
@@ -664,7 +664,7 @@
/* In-chat video: native <video controls> (poster from /thumbs, plays inline on one tap, HTTP-Range streamed).
Wrapped so we can overlay a clear CENTER spinner while it buffers — the OS controls' own buffering hint is
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{position:relative;width:min(260px,74vw);min-height:146px;max-height:340px;border-radius:10px;overflow:hidden;display:block;margin:.15rem 0;background:#000;line-height:0;} /* min-height: the tile keeps its size before the poster lands, so the thread never jumps */
.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;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;}
@@ -678,11 +678,13 @@
.dl-prog .dp-pct{font-weight:700;flex:0 0 auto;font-variant-numeric:tabular-nums;}
.dl-prog .dp-bar{height:4px;border-radius:3px;background:rgba(255,255,255,.22);overflow:hidden;}
.dl-prog .dp-fill{display:block;height:100%;width:0;background:#fff;transition:width .15s linear;}
/* A video owns its whole tile (the player fills it), so its download control is an overlay rather than a
link like images/files get. Once saved it becomes a tick and the tile plays from disk. */
.att-vid-dl{position:absolute;top:8px;right:8px;z-index:4;width:34px;height:34px;border-radius:50%;border:0;background:rgba(0,0,0,.62);color:#fff;display:grid;place-items:center;cursor:pointer;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);}
.att-vid-dl:active{transform:scale(.94);}
.att-vid-saved{position:absolute;top:8px;right:8px;z-index:4;width:22px;height:22px;border-radius:50%;background:rgba(16,185,129,.92);color:#fff;display:grid;place-items:center;pointer-events:none;}
/* One CENTRED control over the masked poster: download → % → play. A corner button collided with the
native control bar, and the bar itself is only turned on once playback starts, so nothing overlaps. */
.att-vid-ov{position:absolute;inset:0;z-index:4;width:100%;height:100%;border:0;padding:0;margin:0;background:rgba(0,0,0,.38);display:grid;place-items:center;cursor:pointer;}
.att-vid.playing .att-vid-ov{display:none;}
.att-vid-btn{width:62px;height:62px;border-radius:50%;background:rgba(0,0,0,.62);border:2px solid rgba(255,255,255,.85);color:#fff;display:grid;place-items:center;font-weight:700;font-size:.92rem;font-variant-numeric:tabular-nums;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);}
.att-vid-ov:active .att-vid-btn{transform:scale(.94);}
.att-vid.saved .att-vid-btn svg{margin-left:3px;} /* optically centre the play triangle */
/* Manage storage */
.stor-tot{display:flex;align-items:center;gap:.45rem;margin:.1rem 0 .5rem;}
.stor-tot b{font-size:1.3rem;}
@@ -1195,7 +1197,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-23-batch163';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-23-batch164';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
@@ -2110,13 +2112,16 @@ function bubbleHTML(m){
// A video has no download button of its own the way an image or file link does, so once it is
// saved it would keep re-streaming on every play. If we have a local copy, play THAT — no network
// at all — and swap the download control for a saved tick.
const rec=bzLibOn()?bzLibGet(A.id):null, loc=rec?bzLibSrc(rec):null;
// Web/PWA: nothing to download to, so stream with the native controls as before.
if(!bzLibOn()) return '<div class="att-vid" data-aid="'+pEsc(A.id)+'"><video class="att-vid-v" src="/stream/'+pEsc(A.id)+'#t=0.1" poster="/thumbs/'+pEsc(A.id)+'" controls playsinline preload="none" title="'+pEsc(A.name)+'"></video><span class="att-vid-spin" aria-hidden="true"></span></div>';
// Native app: ONE centred control over the masked poster — download, and once saved it becomes play.
// Nothing is fetched until it is tapped (no src at all until downloaded), and the native control bar
// only appears once playback starts, so there is nothing for the control to collide with.
const rec=bzLibGet(A.id), loc=rec?bzLibSrc(rec):null;
return '<div class="att-vid'+(loc?' saved':'')+'" data-aid="'+pEsc(A.id)+'" data-name="'+pEsc(A.name)+'" data-mime="'+pEsc(A.mime||'video/mp4')+'">'
+'<video class="att-vid-v" src="'+pEsc(loc||('/stream/'+A.id+'#t=0.1'))+'" poster="/thumbs/'+pEsc(A.id)+'" controls playsinline preload="none" title="'+pEsc(A.name)+'"></video>'
+'<video class="att-vid-v"'+(loc?(' src="'+pEsc(loc)+'"'):'')+' poster="/thumbs/'+pEsc(A.id)+'" playsinline preload="none" title="'+pEsc(A.name)+'"></video>'
+'<span class="att-vid-spin" aria-hidden="true"></span>'
+(!bzLibOn() ? ''
: loc ? '<span class="att-vid-saved" title="Saved on this device">'+ic('check',14)+'</span>'
: '<button class="att-vid-dl" title="Download to this device">'+ic('download',16)+'</button>')
+'<button class="att-vid-ov" title="'+(loc?'Play':'Download to this device')+'"><span class="att-vid-btn">'+ic(loc?'play':'download',26)+'</span></button>'
+'</div>';
})()
: '<a class="att-file" href="/files/'+pEsc(A.id)+'" download="'+pEsc(A.name)+'">'+ic('file',15)+' <span>'+pEsc(A.name)+'</span> <span class="att-sz">'+fmtSize(A.size)+'</span></a>') : '';
@@ -2821,18 +2826,17 @@ function bzSyncVideoTiles(only){
if(!bzLibOn()) return;
document.querySelectorAll('.att-vid[data-aid]').forEach(w=>{
const id=w.getAttribute('data-aid'); if(only && id!==only) return;
const v=w.querySelector('.att-vid-v'); if(!v) return;
const v=w.querySelector('.att-vid-v'), ov=w.querySelector('.att-vid-ov'); if(!v||!ov) return;
const rec=bzLibGet(id), loc=rec?bzLibSrc(rec):null;
const want=loc||('/stream/'+id+'#t=0.1');
if(v.getAttribute('src')!==want){ try{ if(!v.paused) v.pause(); }catch(_){} v.setAttribute('src',want); }
w.classList.toggle('saved', !!loc);
const btn=w.querySelector('.att-vid-dl'), tick=w.querySelector('.att-vid-saved');
if(loc){
if(btn) btn.remove();
if(!tick){ const s=document.createElement('span'); s.className='att-vid-saved'; s.title='Saved on this device'; s.innerHTML=ic('check',14); w.appendChild(s); }
} else {
if(tick) tick.remove();
if(!btn){ const b=document.createElement('button'); b.className='att-vid-dl'; b.title='Download to this device'; b.innerHTML=ic('download',16); w.appendChild(b); }
if(!w.classList.contains('playing') && v.getAttribute('src')!==loc) v.setAttribute('src', loc);
ov.title='Play'; ov.innerHTML='<span class="att-vid-btn">'+ic('play',26)+'</span>';
} else { // local copy gone → back to "not downloaded"
w.classList.remove('playing'); v.controls=false;
try{ if(!v.paused) v.pause(); }catch(_){}
v.removeAttribute('src'); try{ v.load(); }catch(_){}
ov.title='Download to this device'; ov.innerHTML='<span class="att-vid-btn">'+ic('download',26)+'</span>';
}
});
}
@@ -2842,7 +2846,8 @@ async function nativeSaveFile(url, name, meta){
const id=(meta&&meta.id) || (String(url).split('?')[0].split('/').pop()||'');
const fname=(String(name||url.split('/').pop()||'file').replace(/[\/\\:*?"<>|\r\n]+/g,'_')).slice(0,120)||'file';
const mime=(meta&&meta.mime)||bzMimeFromName(fname);
const box=bzDlShow(fname);
const onPct=(meta&&meta.onPct)||null;
const box=onPct?null:bzDlShow(fname); // a video reports % inside its own tile, so no floating chip
const lpath=bzLibPath(fname, mime, id);
try{
const res=await fetch(url, {credentials:'include'}); if(!res.ok) throw new Error('http '+res.status);
@@ -2871,7 +2876,7 @@ async function nativeSaveFile(url, name, meta){
const rem=all.slice(n); pend=rem.length?[rem]:[]; pendLen=rem.length;
}
const pct= total ? Math.max(1, Math.min(99, Math.round(got/total*100))) : -1;
if(pct!==shown){ shown=pct; bzDlSet(box, pct); }
if(pct!==shown){ shown=pct; bzDlSet(box, pct); if(onPct) onPct(pct); }
}
if(pendLen||first){ const all=new Uint8Array(pendLen); let o=0; for(const c of pend){ all.set(c,o); o+=c.length; } await flush(all); }
if(!uri){ try{ const g=await Fs.getUri({ path:lpath, directory:BZ_DIR }); uri=g&&g.uri; }catch(_){} }
@@ -2881,7 +2886,7 @@ async function nativeSaveFile(url, name, meta){
const w=await Fs.writeFile({ path:lpath, data:b64, directory:BZ_DIR, recursive:true });
uri=w&&w.uri; wrote=blob.size;
}
bzDlSet(box,100);
bzDlSet(box,100); if(onPct) onPct(100);
if(id) bzLibPut(id, { path:lpath, uri:uri||'', name:fname, mime, size:wrote||total||0, at:Date.now() });
if(id) bzSyncVideoTiles(id);
toast('Saved to Biz Connect · '+bzLibFolder(mime));
@@ -5306,14 +5311,28 @@ window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(
// player, so the control is an overlay on it. Capture phase so it never reaches the video's own controls.
(function(){
document.addEventListener('click', function(e){
const b=e.target&&e.target.closest&&e.target.closest('.att-vid-dl'); if(!b) return;
const ov=e.target&&e.target.closest&&e.target.closest('.att-vid-ov'); if(!ov) return;
e.preventDefault(); e.stopPropagation();
const w=b.closest('.att-vid'); if(!w || b.dataset.busy) return;
const w=ov.closest('.att-vid'); if(!w || ov.dataset.busy) return;
const id=w.getAttribute('data-aid'); if(!id) return;
b.dataset.busy='1';
const v=w.querySelector('.att-vid-v'); if(!v) return;
const rec=bzLibGet(id);
if(rec){ // already on the device → play it, from disk
const src=bzLibSrc(rec);
if(src && v.getAttribute('src')!==src) v.setAttribute('src', src);
v.controls=true; w.classList.add('playing'); // native bar appears only now, so nothing overlaps
const go=()=>v.play().catch(()=>{});
v.play().catch(()=>{ v.addEventListener('canplay', go, { once:true }); });
return;
}
ov.dataset.busy='1';
const btn=ov.querySelector('.att-vid-btn');
const setPct=(p)=>{ if(btn) btn.textContent = p<0 ? '' : (p+'%'); };
setPct(0);
Promise.resolve(nativeSaveFile('/files/'+encodeURIComponent(id), w.getAttribute('data-name')||'video.mp4',
{ id, mime:w.getAttribute('data-mime')||'video/mp4' }))
.catch(()=>{}).then(()=>{ if(b) delete b.dataset.busy; });
{ id, mime:w.getAttribute('data-mime')||'video/mp4', onPct:setPct }))
.catch(()=>{})
.then(()=>{ delete ov.dataset.busy; bzSyncVideoTiles(id); }); // → play icon, or back to download if it failed
}, true);
if(!bzLibOn()) return;
// The user can delete these from the Files app at any time, so the index is never trusted blindly: