diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js index 9655d9e..4b9d3bb 100644 --- a/frontend/app-seams.test.js +++ b/frontend/app-seams.test.js @@ -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");} diff --git a/frontend/app.js b/frontend/app.js index d77b212..0b093b8 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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; diff --git a/server/server.js b/server/server.js index 771d7dc..9a402fe 100644 --- a/server/server.js +++ b/server/server.js @@ -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); }, });