perf(video): cache attachment auth so Range playback stops stuttering; strict on-demand
Root cause of a downloaded/streaming video buffering repeatedly: every /files Range request (a playing video fires dozens) re-ran the full attachment authorization, which scans the messages table by attachment_id (un-indexed) — a per-chunk table scan = stutter. Now the auth decision is cached per user+attachment for 60s (module-level, bounded), so range requests after the first are ~free. Also: preload='none' (nothing about a video downloads until the user taps play — only the small poster loads), per 'no auto-download'. And the buffering spinner no longer hides on canplay/loadeddata (they fire mid-buffer), so it reliably spins whenever it's buffering. build batch159. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1165,7 +1165,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=6"></script>
|
<script src="/icons.js?v=6"></script>
|
||||||
<script>window.__BUILD='2026-07-22-batch158';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
<script>window.__BUILD='2026-07-22-batch159';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
|
||||||
@@ -2013,7 +2013,7 @@ function bubbleHTML(m){
|
|||||||
const A = m.attachment;
|
const A = m.attachment;
|
||||||
const att = A ? (
|
const att = A ? (
|
||||||
A.isImage ? '<img class="att-img" src="/files/'+pEsc(A.id)+'" data-img="/files/'+pEsc(A.id)+'" alt="'+pEsc(A.name)+'" title="Click to view">'
|
A.isImage ? '<img class="att-img" src="/files/'+pEsc(A.id)+'" data-img="/files/'+pEsc(A.id)+'" alt="'+pEsc(A.name)+'" title="Click to view">'
|
||||||
: A.isVideo ? '<div class="att-vid"><video class="att-vid-v" src="/files/'+pEsc(A.id)+'#t=0.1" poster="/thumbs/'+pEsc(A.id)+'" controls playsinline preload="metadata" title="'+pEsc(A.name)+'"></video><span class="att-vid-spin" aria-hidden="true"></span></div>'
|
: A.isVideo ? '<div class="att-vid"><video class="att-vid-v" src="/files/'+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>'
|
||||||
: '<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>') : '';
|
: '<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>') : '';
|
||||||
const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone'));
|
const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone'));
|
||||||
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
|
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
|
||||||
@@ -5047,7 +5047,7 @@ window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(
|
|||||||
(function(){
|
(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); } };
|
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));
|
['waiting','seeking','stalled'].forEach(ev=>document.addEventListener(ev, set(true), true));
|
||||||
['playing','canplay','seeked','pause','ended','error','loadeddata'].forEach(ev=>document.addEventListener(ev, set(false), 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
|
||||||
})();
|
})();
|
||||||
// #9: swipe a message bubble to the right to reply to it (mobile), like WhatsApp/Teams. The bubble
|
// #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.
|
// follows the finger a little; releasing past the threshold opens the reply composer for that message.
|
||||||
|
|||||||
+28
-14
@@ -9,6 +9,34 @@ const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR, DOWNLOADS_DIR } = require('
|
|||||||
|
|
||||||
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
|
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
|
||||||
|
|
||||||
|
// Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of
|
||||||
|
// ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row.
|
||||||
|
function authAttachmentRaw(id, u) {
|
||||||
|
const a = R.attachments.byId(id);
|
||||||
|
if (!a || a.team_id !== u.team_id) return null;
|
||||||
|
const avatarGroup = R.conversations.byAvatar(id);
|
||||||
|
const carriers = R.messages.allByAttachment(id);
|
||||||
|
const ok = a.uploader_id === u.id
|
||||||
|
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|
||||||
|
|| carriers.some((msg) => msg.conversation_id
|
||||||
|
? R.conversations.isMember(msg.conversation_id, u.id)
|
||||||
|
: (msg.sender_id === u.id || msg.recipient_id === u.id));
|
||||||
|
return ok ? a : null;
|
||||||
|
}
|
||||||
|
// Video playback fires MANY /files Range requests, and authAttachmentRaw scans messages by attachment_id
|
||||||
|
// (un-indexed) each time — that per-chunk scan is what made playback stutter ("buffers and plays…"). Cache the
|
||||||
|
// decision per user+attachment for 60s so range requests after the first are ~free. Bounded to keep memory flat.
|
||||||
|
const _attAuth = new Map();
|
||||||
|
function authAttachment(id, u) {
|
||||||
|
const key = u.id + ':' + id, now = Date.now();
|
||||||
|
const hit = _attAuth.get(key);
|
||||||
|
if (hit && hit.exp > now) return hit.a;
|
||||||
|
const a = authAttachmentRaw(id, u);
|
||||||
|
if (_attAuth.size > 4000) _attAuth.clear();
|
||||||
|
_attAuth.set(key, { a, exp: now + 60000 });
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
function serveStatic(req, res) {
|
function serveStatic(req, res) {
|
||||||
let p = req.url.split('?')[0];
|
let p = req.url.split('?')[0];
|
||||||
if (p === '/') p = '/index.html';
|
if (p === '/') p = '/index.html';
|
||||||
@@ -150,20 +178,6 @@ function handleGet(req, res) {
|
|||||||
rs.pipe(res);
|
rs.pipe(res);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of
|
|
||||||
// ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row.
|
|
||||||
const authAttachment = (id, u) => {
|
|
||||||
const a = R.attachments.byId(id);
|
|
||||||
if (!a || a.team_id !== u.team_id) return null;
|
|
||||||
const avatarGroup = R.conversations.byAvatar(id);
|
|
||||||
const carriers = R.messages.allByAttachment(id);
|
|
||||||
const ok = a.uploader_id === u.id
|
|
||||||
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|
|
||||||
|| carriers.some((msg) => msg.conversation_id
|
|
||||||
? R.conversations.isMember(msg.conversation_id, u.id)
|
|
||||||
: (msg.sender_id === u.id || msg.recipient_id === u.id));
|
|
||||||
return ok ? a : null;
|
|
||||||
};
|
|
||||||
// Video POSTER thumbnail — first frame extracted with ffmpeg, cached next to the file. Cosmetic: if ffmpeg
|
// Video POSTER thumbnail — first frame extracted with ffmpeg, cached next to the file. Cosmetic: if ffmpeg
|
||||||
// is missing or fails we 404 and the <video> just falls back to its own (black) poster.
|
// is missing or fails we 404 and the <video> just falls back to its own (black) poster.
|
||||||
if (pathOnly.startsWith('/thumbs/')) {
|
if (pathOnly.startsWith('/thumbs/')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user