3 Commits

4 changed files with 98 additions and 4 deletions

View File

@@ -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");}

View File

@@ -105,8 +105,17 @@ function saveDataToStorage(jsonStr) {
// is the browser's own claim; if a copy still fails to play here, the device
// is remembered (NO_HEVC_KEY) and stops asking — it gets H.264 as before.
const NO_HEVC_KEY = 'ytpNoHevc';
// canPlayType only says the browser can decode HEVC — often in software, which
// stutters the picture while the audio stays fine. Ask MediaCapabilities whether
// it is hardware-accelerated (powerEfficient) and smooth; remember the answer.
const HEVC_HW_KEY = 'ytpHevcHw';
try {
navigator.mediaCapabilities.decodingInfo({ type: 'file', video: { contentType: 'video/mp4; codecs="hvc1.1.6.L93.B0"', width: 1280, height: 720, bitrate: 1500000, framerate: 30 } })
.then((i) => { try { localStorage.setItem(HEVC_HW_KEY, i.supported && i.smooth && i.powerEfficient ? '1' : '0'); } catch { /* storage blocked */ } })
.catch(() => {});
} catch { /* no MediaCapabilities */ }
function hevcCapable() {
try { if (localStorage.getItem(NO_HEVC_KEY) === '1') return false; } catch { /* storage blocked */ }
try { if (localStorage.getItem(NO_HEVC_KEY) === '1' || localStorage.getItem(HEVC_HW_KEY) === '0') return false; } catch { /* storage blocked */ }
try {
return document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"') !== '';
} catch { return false; }
@@ -2013,6 +2022,36 @@ document.addEventListener('play', () => {
preload(m, { quiet: true });
}, true);
// Dropped-frame watchdog: an HEVC server copy that this device decodes in
// software drops frames while the audio plays on. Sample the first ~30 s of
// each play; if it is clearly struggling, stop asking this device for HEVC and
// restart the song on the compatible stream, from where it is.
{
const dropWatch = { id: '', t: 0, tries: 0 };
document.addEventListener('playing', (e) => {
const v = e.target;
if (!v || v.tagName !== 'VIDEO' || !current || !current.meta || !v.getVideoPlaybackQuality) return;
if (!(current.qualities || []).some((q) => q.codec === 'hevc') || !current.serverCached) return;
if (dropWatch.id === current.meta.id && dropWatch.tries >= 6) return;
if (dropWatch.id !== current.meta.id) { dropWatch.id = current.meta.id; dropWatch.tries = 0; }
const id = current.meta.id, at = v.getVideoPlaybackQuality(), t0 = Date.now();
const check = setInterval(() => {
if (!current || !current.meta || current.meta.id !== id || v.paused || Date.now() - t0 > 30000) { clearInterval(check); return; }
dropWatch.tries++;
const q = v.getVideoPlaybackQuality();
const total = q.totalVideoFrames - at.totalVideoFrames, dropped = q.droppedVideoFrames - at.droppedVideoFrames;
if (total >= 60 && dropped / total > 0.2) {
clearInterval(check);
try { localStorage.setItem(NO_HEVC_KEY, '1'); } catch { /* storage blocked */ }
toast('Video was choppy on this device — switching to the compatible format.', { duration: 4000 });
const pos = v.currentTime || 0;
if (pos > 1 && data.resumePositions && rememberPosEnabled()) data.resumePositions[id] = Math.floor(pos);
Player.loadVideo(current.meta, { preferStream: true, nocache: true, resume: true, reveal: false });
}
}, 3000);
}, true);
}
// ============================================================================
// Video editor — cut parts out of a video and save a custom offline copy
@@ -2796,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;
@@ -2985,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;
@@ -2993,9 +3072,13 @@ function chooseQuality() {
const match = qs.find((q) => q.label === pref);
if (match) return match;
}
// auto: best quality at or below 720p, else the lowest available.
// auto: best quality at or below 720p (480p on touch phones, where a lossy
// link rebuffers the picture while the separate audio keeps playing),
// else the lowest available.
let cap = 720;
try { if (matchMedia('(pointer: coarse)').matches && Math.min(screen.width, screen.height) < 600) cap = 480; } catch {}
const sorted = [...qs].sort((a, b) => b.height - a.height);
return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1];
return sorted.find((q) => q.height <= cap) || sorted[sorted.length - 1];
}
function buildQualityMenu() {

View File

@@ -2267,6 +2267,13 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
.player-pane:not(.empty) .player-stage {
box-shadow: 0 0 0 1px var(--line) inset;
}
/* The per-layout themes (html[data-layout=...] .topbar etc.) out-rank the
* rules above and bring 20px blurs back over the playing video, as do the
* lyrics bar buttons and section rail. Nothing on a phone PWA may blur. */
*, *::before, *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
/* ============================================================================

View File

@@ -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); },
});