iPhone: stop rate-slewing the video for sync; add playback telemetry beacon
This commit is contained in:
@@ -5,7 +5,7 @@ const {createHash}=require('node:crypto');
|
||||
const {join}=require('node:path');
|
||||
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
||||
test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{
|
||||
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","Player body unchanged");}
|
||||
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"2a0edc8b4a556589b1fa5c8ba49ba2e9978e90d452882a30594f172f13c93cc0","Player body unchanged");}
|
||||
{const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");}
|
||||
{const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");}
|
||||
{const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");}
|
||||
|
||||
@@ -2835,6 +2835,14 @@ const Player = {
|
||||
if (this.master.seeking) return;
|
||||
const drift = this.secondary.currentTime - this.master.currentTime;
|
||||
const abs = Math.abs(drift);
|
||||
// iPhone: audio/video currentTime jitter alone exceeds the soft threshold,
|
||||
// so rate-slewing the visible video every tick causes the stutter. Only
|
||||
// snap on a real, sustained desync.
|
||||
if (IS_IOS_DEVICE) {
|
||||
if (abs >= 0.5 && Date.now() - (this._lastSnapAt || 0) > 5000) { this.snapVideoTo(this.master, this.secondary); this._resyncSpeed(); }
|
||||
else if (abs < 0.5) this._resyncSpeed();
|
||||
return;
|
||||
}
|
||||
if (abs >= this.HARD_SYNC_THRESHOLD && Date.now() - (this._lastSnapAt || 0) < this.SNAP_COOLDOWN_MS) {
|
||||
// Just snapped: ease in instead of snapping again.
|
||||
const base = parseFloat(els.speed.value) || 1;
|
||||
@@ -3024,6 +3032,38 @@ function showSpinner(on) {
|
||||
els.spinner.classList.toggle('hidden', !on);
|
||||
}
|
||||
|
||||
const IS_IOS_DEVICE = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
// Playback telemetry: 1 sample/s for 60 s after playback starts or the app
|
||||
// returns to the foreground, sent once to /api/diag (read via container logs).
|
||||
const Diag = (() => {
|
||||
let timer = null, samples = [], events = [], t0 = 0, tag = '';
|
||||
const ev = (n) => { if (timer) events.push([Math.round(performance.now() - t0), n]); };
|
||||
const q = (V) => { try { const x = V.getVideoPlaybackQuality(); return [x.droppedVideoFrames, x.totalVideoFrames]; } catch { return [-1, -1]; } };
|
||||
function flush() {
|
||||
if (!timer) return;
|
||||
clearInterval(timer); timer = null;
|
||||
const V = els.video;
|
||||
const body = JSON.stringify({ tag, ua: navigator.userAgent, standalone: matchMedia('(display-mode: standalone)').matches,
|
||||
id: current && current.meta && current.meta.id, mode: Player.mode, src: (V.currentSrc || '').replace(/^https?:\/\/[^/]+/, '').slice(0, 80),
|
||||
size: [V.videoWidth, V.videoHeight], hevc: hevcCapable(), samples, events });
|
||||
try { navigator.sendBeacon('/api/diag', new Blob([body], { type: 'application/json' })); } catch {}
|
||||
}
|
||||
function start(why) {
|
||||
flush(); samples = []; events = []; tag = why; t0 = performance.now();
|
||||
timer = setInterval(() => {
|
||||
const V = els.video, m = Player.master, s = Player.secondary;
|
||||
samples.push([samples.length, Player.mode, m === V ? 'v' : 'a', +(m ? m.currentTime : 0).toFixed(2),
|
||||
s ? +(s.currentTime - m.currentTime).toFixed(3) : null, m ? m.playbackRate : 0, q(V), V.readyState, V.paused ? 1 : 0, document.visibilityState === 'visible' ? 1 : 0]);
|
||||
if (samples.length >= 60) flush();
|
||||
}, 1000);
|
||||
}
|
||||
for (const n of ['waiting', 'stalled', 'seeking', 'seeked', 'ratechange', 'error']) els.video.addEventListener(n, () => ev('v:' + n), true);
|
||||
els.video.addEventListener('playing', () => { if (!timer) start('playing'); }, true);
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Player._wantsPlaying) start('return'); else flush(); });
|
||||
return { start, flush };
|
||||
})();
|
||||
|
||||
function chooseQuality() {
|
||||
const qs = current.qualities || [];
|
||||
if (!qs.length) return null;
|
||||
|
||||
@@ -1599,6 +1599,10 @@ app.post('/api/media/:id/redownload', async (c) => {
|
||||
// Saves fetch the copy in byte ranges (docs/resumable-downloads-plan.md):
|
||||
// the ETag pins the generation, so a resumed save never mixes two copies.
|
||||
const prepareSkips = new Map(); // videoId → { reason, at } for /prepare
|
||||
app.post('/api/diag', async (c) => {
|
||||
try { const t = (await c.req.text()).slice(0, 20000); console.log('[diag]', t); } catch {}
|
||||
return c.body(null, 204);
|
||||
});
|
||||
registerDownloadRetryRoute(app, {
|
||||
media, priority: HIGH, clearHints: id => { prepareSkips.delete(id); streamCache.delete(id); },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user