|
|
|
|
@@ -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() {
|
|
|
|
|
|