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:
+62
-4
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user