feat(download): real % progress, and stream to disk instead of buffering

Uploads showed a bar + %; downloads showed nothing. On the native app that gap is
worse than on web, because there is no browser download UI behind it — the app
fetches the bytes itself, so a large video looked like a frozen tap.

- Adds the same chip an upload uses (name, bar, %) driven by Content-Length.
  Falls back to an indeterminate "…" when the server sends no length.
- Streams the response and appends to the file in 3-byte-aligned blocks rather
  than holding blob + base64 simultaneously. The old path peaked around 250 MB of
  memory for a 75 MB video, which is enough to get a WebView killed on a phone.
  Verified byte-exact against empty / 1 B / 2 B (base64 padding edges) / ragged
  chunk sizes / 27 MB, reassembling identical bytes every time.
- Older WebViews without streams keep the previous one-shot path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:18:50 +05:30
parent 310f37f29b
commit cc8f7d1eb8
+62 -4
View File
@@ -670,6 +670,14 @@
.att-vid.buffering .att-vid-spin{opacity:1;animation:ptrspin .8s linear infinite;} .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. */ /* Dim the frame while buffering so the centre spinner reads instantly against a bright poster. */
.att-vid.buffering .att-vid-v{filter:brightness(.72);} .att-vid.buffering .att-vid-v{filter:brightness(.72);}
/* Download progress chip. The native save path fetches the bytes itself, so there is no browser download
UI to lean on — this mirrors the upload bar's bar + %, so a download reads the same as an upload. */
.dl-prog{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(env(safe-area-inset-bottom,0px) + 84px);z-index:120;background:rgba(17,24,39,.94);color:#fff;border-radius:12px;padding:.55rem .75rem;width:min(320px,84vw);box-shadow:0 6px 24px rgba(0,0,0,.38);font-size:.82rem;display:grid;gap:.4rem;}
.dl-prog .dp-top{display:flex;align-items:center;gap:.45rem;}
.dl-prog .dp-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.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;}
.bubble.mine .att-file{background:rgba(255,255,255,.18);border-color:rgba(255,255,255,.3);} .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;} .att-file .att-sz{opacity:.65;font-size:.75rem;flex:0 0 auto;}
/* groups */ /* groups */
@@ -1167,7 +1175,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-23-batch161';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-23-batch162';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. // 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 // We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -2638,17 +2646,67 @@ function openInvitePicker(room){
// WebView (cookie present) and hand the bytes to the OS save/share sheet — a real inline download. That needs // WebView (cookie present) and hand the bytes to the OS save/share sheet — a real inline download. That needs
// the Filesystem+Share plugins (added to the mobile app; ships in the next TestFlight build). Until then, fall // the Filesystem+Share plugins (added to the mobile app; ships in the next TestFlight build). Until then, fall
// back to iOS long-press "Save to Photos" for images (works today) so nothing breaks. Desktop/web untouched. // back to iOS long-press "Save to Photos" for images (works today) so nothing breaks. Desktop/web untouched.
// Download PROGRESS. An upload shows a bar + % (uploadFile below); a native save had none, because it
// fetches the bytes itself and there is no browser download UI behind it. Same chip, same %.
function bzDlShow(name){
let el=document.getElementById('dlProg');
if(!el){ el=document.createElement('div'); el.id='dlProg'; el.className='dl-prog'; document.body.appendChild(el); }
el.innerHTML='<div class="dp-top">'+ic('download',15)+'<span class="dp-name"></span><span class="dp-pct">0%</span></div>'
+'<div class="dp-bar"><span class="dp-fill"></span></div>';
el.querySelector('.dp-name').textContent=name||'file';
return el;
}
function bzDlSet(el,pct){
if(!el) return; const f=el.querySelector('.dp-fill'), p=el.querySelector('.dp-pct');
if(pct<0){ if(p) p.textContent='…'; if(f) f.style.width='100%'; return; } // server sent no Content-Length
if(f) f.style.width=pct+'%'; if(p) p.textContent=pct+'%';
}
function bzDlHide(el){ if(el&&el.parentNode) el.parentNode.removeChild(el); }
function bzB64(u8){ let s=''; const CH=0x8000; for(let i=0;i<u8.length;i+=CH) s+=String.fromCharCode.apply(null,u8.subarray(i,i+CH)); return btoa(s); }
async function nativeSaveFile(url, name){ async function nativeSaveFile(url, name){
const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem, Sh=P&&P.Share; const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem, Sh=P&&P.Share;
if(!Fs||!Sh) return false; if(!Fs||!Sh) return false;
const fname=(name||url.split('/').pop()||'file').replace(/[^\w.\- ]+/g,'_');
const box=bzDlShow(fname);
try{ try{
const res=await fetch(url, {credentials:'include'}); if(!res.ok) throw new Error('http '+res.status); const res=await fetch(url, {credentials:'include'}); if(!res.ok) throw new Error('http '+res.status);
const total=+(res.headers.get('Content-Length')||0);
let uri=null;
if(res.body && res.body.getReader){
// Stream to disk instead of buffering. The old path held blob + base64 at once — ~250 MB peak for a
// 75 MB video, enough to get the WebView killed on a phone. Flush in 3-byte-aligned blocks so each
// base64 chunk concatenates cleanly onto the file.
const reader=res.body.getReader(); const BLOCK=3*65536;
let pend=[], pendLen=0, got=0, first=true, shown=-1;
const flush=async(u8)=>{
const data=bzB64(u8);
const w= first ? await Fs.writeFile({ path:fname, data, directory:'CACHE' })
: await Fs.appendFile({ path:fname, data, directory:'CACHE' });
first=false; if(w&&w.uri) uri=w.uri;
};
for(;;){
const r=await reader.read(); if(r.done) break;
pend.push(r.value); pendLen+=r.value.length; got+=r.value.length;
if(pendLen>=BLOCK){
const all=new Uint8Array(pendLen); let o=0; for(const c of pend){ all.set(c,o); o+=c.length; }
const n=Math.floor(all.length/3)*3;
await flush(all.subarray(0,n));
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(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:fname, directory:'CACHE' }); uri=g&&g.uri; }catch(_){} }
} else { // no streams (older WebView) — original one-shot path
const blob=await res.blob(); const blob=await res.blob();
const b64=await new Promise((ok,no)=>{ const r=new FileReader(); r.onloadend=()=>ok(String(r.result).split(',')[1]||''); r.onerror=no; r.readAsDataURL(blob); }); const b64=await new Promise((ok,no)=>{ const r=new FileReader(); r.onloadend=()=>ok(String(r.result).split(',')[1]||''); r.onerror=no; r.readAsDataURL(blob); });
const fname=(name||url.split('/').pop()||'file').replace(/[^\w.\- ]+/g,'_'); const w=await Fs.writeFile({ path:fname, data:b64, directory:'CACHE' }); uri=w&&w.uri;
const w=await Fs.writeFile({ path:fname, data:b64, directory:'CACHE' }); }
await Sh.share({ url:w.uri, title:fname }); // iOS sheet: Save to Files / Save Image / share bzDlSet(box,100);
await Sh.share({ url:uri, title:fname }); // iOS sheet: Save to Files / Save Image / share
}catch(e){ toast("Couldn't save the file"); } }catch(e){ toast("Couldn't save the file"); }
finally{ bzDlHide(box); }
return true; // handled either way — never fall through to navigation return true; // handled either way — never fall through to navigation
} }
(function(){ var C=window.Capacitor; var p=(C&&C.getPlatform)?C.getPlatform():''; if(p!=='ios'&&p!=='android') return; (function(){ var C=window.Capacitor; var p=(C&&C.getPlatform)?C.getPlatform():''; if(p!=='ios'&&p!=='android') return;