iPhone: EQ and loudness levelling for saved songs that keep playing when locked
iOS stops Web Audio when the screen locks, so live processing can't reach a locked phone. For saved songs, EqRender renders the same chain as the live EQ (5 bands -> level gain -> limiter) offline into a 32 kHz stereo WAV in OPFS (eq-renders/<id>.wav, written by the OPFS worker's new write op), one song at a time in the background. Playback then takes the picture from the saved video (muted) and the sound from that copy in dual mode — plain media playback, so it continues with the screen locked; live gains are zeroed so nothing is applied twice. Copies are re-rendered when the EQ curve or a song's loudness changes and removed when nothing needs applying. - Level volume is now on by default everywhere (on iPhone it reaches saved songs through these copies). - New iPhone-only Settings -> Playback -> Live sound processing (off by default): live Web Audio EQ/levelling for streamed songs, stops when locked. - The EQ dialog works on iPhone and explains where the curve applies.
This commit is contained in:
211
frontend/app.js
211
frontend/app.js
@@ -628,7 +628,8 @@ const DEFAULT_SETTINGS = {
|
||||
shuffle: false, // shuffle upcoming tracks when playing a list
|
||||
theme: 'dark', // 'dark' | 'light' | 'contrast'
|
||||
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
|
||||
levelVolume: null, // loudness levelling; null = default (on, except iOS where it is opt-in)
|
||||
levelVolume: null, // loudness levelling; null = on (on iPhone: applied to saved songs' rendered copies)
|
||||
liveAudioIOS: false, // iPhone: live Web Audio EQ/levelling for streamed songs (stops when the screen locks)
|
||||
parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
|
||||
density: 'comfortable', // 'comfortable' | 'compact'
|
||||
perfMode: false, // disable heavy visual effects for speed
|
||||
@@ -655,7 +656,15 @@ let queueSource = ''; // label of what's playing ('queue','pl
|
||||
let playFullMode = false; // "play in full" session: ignore resume positions,
|
||||
// A-B markers, shuffle, loop and repeat — play the
|
||||
// list start-to-finish, then stop.
|
||||
let current = null; // { meta, qualities, audioUrl, localUrl? }
|
||||
let current = null; // { meta, qualities, audioUrl, localUrl?, eqAudioUrl? }
|
||||
// iPhone: object URL of the playing saved song's rendered EQ/levelled sound.
|
||||
let _currentEqUrl = null;
|
||||
async function useEqRender(id) {
|
||||
if (_currentEqUrl) { URL.revokeObjectURL(_currentEqUrl); _currentEqUrl = null; }
|
||||
if (typeof EqRender === 'undefined' || !WEB) return null;
|
||||
try { _currentEqUrl = await EqRender.urlFor(id); } catch { _currentEqUrl = null; }
|
||||
return _currentEqUrl;
|
||||
}
|
||||
let dragSource = -1; // index of card being dragged
|
||||
let listFilter = '';
|
||||
let selectMode = false;
|
||||
@@ -1801,6 +1810,7 @@ async function preload(video, { quiet = false, mux = false } = {}) {
|
||||
SaveQueue.remove(id);
|
||||
}
|
||||
if (res && res.ok && res.cached) {
|
||||
if (typeof EqRender !== 'undefined') EqRender.schedule(id);
|
||||
cachedIds.add(id);
|
||||
cacheMutations++;
|
||||
recordDeviceFile(id, res);
|
||||
@@ -2235,7 +2245,9 @@ const Player = {
|
||||
}
|
||||
} catch { /* fall through to streaming */ }
|
||||
if (localUrl) {
|
||||
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl };
|
||||
const eqAudioUrl = await useEqRender(videoObj.id);
|
||||
if (seq !== this._loadSeq) return;
|
||||
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl, eqAudioUrl };
|
||||
this.afterLoad();
|
||||
this.fallbackQueue = []; this.fbIndex = 0;
|
||||
this.attach(null);
|
||||
@@ -2265,7 +2277,8 @@ const Player = {
|
||||
if (seq !== this._loadSeq) return;
|
||||
if (localUrl) {
|
||||
_currentBlobUrl = localUrl;
|
||||
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl };
|
||||
const eqAudioUrl = await useEqRender(videoObj.id);
|
||||
current = { meta: { ...videoObj }, qualities: [], audioUrl: null, localUrl, eqAudioUrl };
|
||||
this.afterLoad();
|
||||
this.fallbackQueue = []; this.fbIndex = 0;
|
||||
this.attach(null);
|
||||
@@ -2454,13 +2467,33 @@ const Player = {
|
||||
V.pause(); A.pause();
|
||||
this.stopDrift();
|
||||
|
||||
if (current.localUrl && current.eqAudioUrl && !audioOnly) {
|
||||
// iPhone: the saved video's picture (muted) + its rendered EQ/levelled
|
||||
// sound, kept in sync like an adaptive stream.
|
||||
this.mode = 'dual';
|
||||
this.master = V;
|
||||
this.secondary = A;
|
||||
V.muted = true;
|
||||
V.src = current.localUrl;
|
||||
A.src = current.eqAudioUrl;
|
||||
els.art.classList.add('hidden');
|
||||
this.applyVolume();
|
||||
this.applySpeed();
|
||||
this.master.load();
|
||||
this.secondary.load();
|
||||
const onReadyEq = () => { this.master.removeEventListener('canplay', onReadyEq); this._startPlayback(); };
|
||||
this.master.addEventListener('canplay', onReadyEq);
|
||||
this.startDrift();
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.localUrl) {
|
||||
// Cached single file (carries its own audio). Play it directly.
|
||||
if (audioOnly) {
|
||||
this.mode = 'audio';
|
||||
this.master = A;
|
||||
this.secondary = null;
|
||||
A.src = current.localUrl;
|
||||
A.src = current.eqAudioUrl || current.localUrl;
|
||||
V.removeAttribute('src'); V.load();
|
||||
els.art.classList.remove('hidden');
|
||||
els.artImg.src = current.meta.thumbnail || '';
|
||||
@@ -6380,6 +6413,7 @@ const Level = (() => {
|
||||
let seq = 0;
|
||||
const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } };
|
||||
const remember = (id, lufs) => {
|
||||
if (typeof EqRender !== 'undefined') setTimeout(() => EqRender.schedule(id), 0);
|
||||
try {
|
||||
const m = read(); m[id] = Math.round(lufs * 10) / 10;
|
||||
const ids = Object.keys(m); if (ids.length > 3000) ids.slice(0, ids.length - 3000).forEach((k) => delete m[k]);
|
||||
@@ -6389,7 +6423,7 @@ const Level = (() => {
|
||||
const enabled = () => {
|
||||
const v = data.settings.levelVolume;
|
||||
if (v === true || v === false) return v;
|
||||
return !EQ.isIOS(); // default: on, except iPhone/iPad where it is opt-in
|
||||
return true; // on by default; on iPhone it reaches saved songs via EqRender
|
||||
};
|
||||
const gainFor = (lufs) => Math.max(-12, Math.min(9, Loudness.TARGET - lufs));
|
||||
|
||||
@@ -6460,6 +6494,128 @@ const Level = (() => {
|
||||
return { onTrack, enabled, apply, gainFor, remembered: (id) => read()[id] };
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
// iPhone: EQ + levelling rendered into saved songs
|
||||
// ============================================================================
|
||||
// iOS stops Web Audio when the screen locks, so live processing can't reach a
|
||||
// locked phone. For songs saved on the device, the same chain as the live EQ
|
||||
// (5 bands → level gain → limiter) is rendered offline into a WAV copy in OPFS
|
||||
// (eq-renders/<id>.wav, 32 kHz stereo). Playback then takes the picture from
|
||||
// the saved video (muted) and the sound from that copy — plain media playback,
|
||||
// so it keeps going when locked. One render at a time, in the background.
|
||||
const EqRender = (() => {
|
||||
const DIR = 'eq-renders', KEY = 'ytpEqRenders', RATE = 32000, MAX_SECONDS = 12 * 60;
|
||||
const active = () => EQ.isIOS() && WEB && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined';
|
||||
const readMap = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } };
|
||||
const writeMap = (m) => { try { localStorage.setItem(KEY, JSON.stringify(m)); } catch { /* blocked */ } };
|
||||
const queue = new Set();
|
||||
let running = false;
|
||||
|
||||
// What a song's copy should carry right now, or null when the original is fine.
|
||||
function wanted(id) {
|
||||
const g = EQ.gains();
|
||||
const lufs = Level.enabled() ? Level.remembered(id) : null;
|
||||
const levelDb = lufs == null ? 0 : Math.round(Level.gainFor(lufs) * 2) / 2;
|
||||
if (g.every((v) => v === 0) && Math.abs(levelDb) < 0.5) return null;
|
||||
return { gains: g, levelDb, sig: g.join(',') + '|' + levelDb };
|
||||
}
|
||||
async function dir() { return (await navigator.storage.getDirectory()).getDirectoryHandle(DIR, { create: true }); }
|
||||
async function drop(id) {
|
||||
try { await (await dir()).removeEntry(id + '.wav'); } catch { /* none */ }
|
||||
const m = readMap(); if (m[id]) { delete m[id]; writeMap(m); }
|
||||
}
|
||||
|
||||
// Object URL of an up-to-date copy, or null (then the original plays as is).
|
||||
async function urlFor(id) {
|
||||
if (!active()) return null;
|
||||
const w = wanted(id);
|
||||
if (!w) return null;
|
||||
if (readMap()[id] !== w.sig) { schedule(id); return null; }
|
||||
try { return URL.createObjectURL(await (await (await dir()).getFileHandle(id + '.wav')).getFile()); }
|
||||
catch { schedule(id); return null; }
|
||||
}
|
||||
|
||||
function wav(buffer) {
|
||||
const ch = Math.min(2, buffer.numberOfChannels), n = buffer.length, rate = buffer.sampleRate;
|
||||
const out = new ArrayBuffer(44 + n * ch * 2), v = new DataView(out);
|
||||
const str = (o, t) => { for (let i = 0; i < t.length; i++) v.setUint8(o + i, t.charCodeAt(i)); };
|
||||
str(0, 'RIFF'); v.setUint32(4, 36 + n * ch * 2, true); str(8, 'WAVE'); str(12, 'fmt ');
|
||||
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, ch, true); v.setUint32(24, rate, true);
|
||||
v.setUint32(28, rate * ch * 2, true); v.setUint16(32, ch * 2, true); v.setUint16(34, 16, true);
|
||||
str(36, 'data'); v.setUint32(40, n * ch * 2, true);
|
||||
const data = [buffer.getChannelData(0), buffer.getChannelData(ch > 1 ? 1 : 0)];
|
||||
let o = 44;
|
||||
for (let i = 0; i < n; i++) for (let c = 0; c < ch; c++) {
|
||||
const x = Math.max(-1, Math.min(1, data[c][i]));
|
||||
v.setInt16(o, x < 0 ? x * 0x8000 : x * 0x7fff, true); o += 2;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeViaWorker(name, buffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = new Worker('/opfs-worker.js');
|
||||
w.onmessage = (e) => { w.terminate(); (e.data && e.data.type === 'written') ? resolve() : reject(new Error((e.data && e.data.error) || 'write failed')); };
|
||||
w.onerror = () => { w.terminate(); reject(new Error('worker failed')); };
|
||||
w.postMessage({ op: 'write', dir: DIR, name, buffer }, [buffer]);
|
||||
});
|
||||
}
|
||||
|
||||
async function render(id) {
|
||||
if (!cachedIds.has(id)) { await drop(id); return; }
|
||||
const w = wanted(id);
|
||||
if (!w) { await drop(id); return; }
|
||||
if (readMap()[id] === w.sig) return;
|
||||
const file = await window.OPFS.getFileObject(id);
|
||||
if (!file) return;
|
||||
const buf = await file.arrayBuffer();
|
||||
const decoded = await new Promise((res, rej) => {
|
||||
// Safari's callback form and the promise form both resolve here.
|
||||
const p = new OfflineAudioContext(2, RATE, RATE).decodeAudioData(buf, res, rej);
|
||||
if (p && p.then) p.then(res, rej);
|
||||
}).catch(() => null);
|
||||
if (!decoded || decoded.duration > MAX_SECONDS) return;
|
||||
const off = new OfflineAudioContext(2, decoded.length, RATE);
|
||||
const src = off.createBufferSource(); src.buffer = decoded;
|
||||
let node = src;
|
||||
EQ.bands.forEach((b, i) => {
|
||||
const f = off.createBiquadFilter(); f.type = b.type; f.frequency.value = b.f; f.Q.value = 1.0; f.gain.value = w.gains[i];
|
||||
node.connect(f); node = f;
|
||||
});
|
||||
const gain = off.createGain(); gain.gain.value = Math.pow(10, w.levelDb / 20);
|
||||
const lim = off.createDynamicsCompressor();
|
||||
lim.threshold.value = -1; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.003; lim.release.value = 0.25;
|
||||
node.connect(gain); gain.connect(lim); lim.connect(off.destination);
|
||||
src.start();
|
||||
const out = await off.startRendering();
|
||||
if (wanted(id)?.sig !== w.sig) return; // settings changed meanwhile — the queue re-runs it
|
||||
await writeViaWorker(id + '.wav', wav(out));
|
||||
const m = readMap(); m[id] = w.sig; writeMap(m);
|
||||
}
|
||||
|
||||
async function pump() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
while (queue.size) {
|
||||
const id = queue.values().next().value;
|
||||
queue.delete(id);
|
||||
try { await render(id); } catch { /* skip this one; it is retried next time it plays */ }
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
} finally { running = false; }
|
||||
}
|
||||
function schedule(id) { if (!active() || !id) return; queue.add(id); setTimeout(pump, 1500); }
|
||||
// Every saved song (EQ changed); also prunes copies of songs no longer saved.
|
||||
function scheduleAll() {
|
||||
if (!active()) return;
|
||||
cachedIds.forEach((id) => queue.add(id));
|
||||
Object.keys(readMap()).forEach((id) => { if (!cachedIds.has(id)) queue.add(id); });
|
||||
setTimeout(pump, 1500);
|
||||
}
|
||||
return { urlFor, schedule, scheduleAll, active, wav };
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
// Picture-in-picture
|
||||
// ============================================================================
|
||||
@@ -6531,7 +6687,12 @@ const EQ = (() => {
|
||||
const st = { ctx: null, filters: null, sources: new Map() };
|
||||
// Web Audio is off on iOS (it silences playback when the screen locks)
|
||||
// unless the user opted in through "Level volume" (Settings → Playback).
|
||||
const available = () => !!AC && (!isIOS || data.settings.levelVolume === true);
|
||||
// Live Web Audio is off on iOS (it silences playback when the screen locks)
|
||||
// unless the user opted into "Live sound processing". Saved songs on iPhone
|
||||
// get EQ + levelling rendered into a copy instead (EqRender).
|
||||
const available = () => !!AC && (!isIOS || data.settings.liveAudioIOS === true);
|
||||
// A saved song playing its rendered copy already carries EQ and levelling.
|
||||
const rendered = () => !!(current && current.eqAudioUrl);
|
||||
const gains = () => {
|
||||
const g = data.settings.eq && Array.isArray(data.settings.eq.g) ? data.settings.eq.g : EQ_PRESETS.flat.g;
|
||||
return EQ_BANDS.map((_, i) => Math.max(-12, Math.min(12, Number(g[i]) || 0)));
|
||||
@@ -6579,7 +6740,7 @@ const EQ = (() => {
|
||||
if (!build()) return;
|
||||
attach(els.video);
|
||||
attach(els.audio);
|
||||
const g = gains();
|
||||
const g = rendered() ? EQ_BANDS.map(() => 0) : gains();
|
||||
st.filters.forEach((f, i) => { f.gain.value = g[i]; });
|
||||
if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {});
|
||||
renderButton();
|
||||
@@ -6598,15 +6759,14 @@ const EQ = (() => {
|
||||
persist();
|
||||
apply();
|
||||
renderButton();
|
||||
if (typeof EqRender !== 'undefined') EqRender.scheduleAll();
|
||||
}
|
||||
|
||||
function open() {
|
||||
const body = document.createElement('div');
|
||||
body.className = 'eq-panel';
|
||||
if (!available()) {
|
||||
body.innerHTML = `<p class="modal-text">${isIOS
|
||||
? 'The equalizer isn’t available on iPhone and iPad: iOS stops Web Audio when the screen locks, which would cut background playback.'
|
||||
: 'This browser has no Web Audio support.'}</p>`;
|
||||
if (!AC) {
|
||||
body.innerHTML = '<p class="modal-text">This browser has no Web Audio support.</p>';
|
||||
showModal('🎚 Equalizer', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
|
||||
return;
|
||||
}
|
||||
@@ -6621,7 +6781,9 @@ const EQ = (() => {
|
||||
<input type="range" min="-12" max="12" step="1" value="${g[i]}" data-band="${i}" aria-label="${b.label} Hz" />
|
||||
<span class="eq-f">${b.label}</span>
|
||||
</label>`).join('')}</div>
|
||||
<p class="sp-note">Changes apply instantly. Hz bands from bass (left) to treble (right).</p>`;
|
||||
<p class="sp-note">${isIOS && !available()
|
||||
? 'On iPhone the equalizer is applied to <strong>saved songs</strong> (a processed copy is made in the background, so they keep playing with the screen locked). For streamed songs, turn on Live sound processing in Settings → Playback.'
|
||||
: 'Changes apply instantly. Hz bands from bass (left) to treble (right).'}</p>`;
|
||||
};
|
||||
draw();
|
||||
body.addEventListener('click', (e) => {
|
||||
@@ -6665,6 +6827,7 @@ const EQ = (() => {
|
||||
// Set the levelling gain (dB). 0 dB never wires the graph on its own.
|
||||
function setLevel(db) {
|
||||
if (!available()) return false;
|
||||
if (rendered()) db = 0;
|
||||
if (!st.ctx && Math.abs(db) < 0.3) return true;
|
||||
if (!build()) return false;
|
||||
attach(els.video);
|
||||
@@ -6675,7 +6838,7 @@ const EQ = (() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
return { open, apply, renderButton, available, analyser, setLevel, isIOS: () => isIOS, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null,
|
||||
return { open, apply, renderButton, available, analyser, setLevel, gains, bands: EQ_BANDS, isIOS: () => isIOS, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null,
|
||||
levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null }) };
|
||||
})();
|
||||
|
||||
@@ -9285,11 +9448,18 @@ async function renderSettings() {
|
||||
<span>
|
||||
Level volume between songs
|
||||
<small>${EQ.isIOS()
|
||||
? 'Brings every song to the same loudness (−14 LUFS). On iPhone this stops the sound when the screen locks.'
|
||||
? 'Brings every song to the same loudness (−14 LUFS). On iPhone it is built into saved songs, so they keep playing with the screen locked.'
|
||||
: 'Brings every song to the same loudness (−14 LUFS), measured on this device and shared.'}</small>
|
||||
</span>
|
||||
<input id="setLevelVolume" type="checkbox" ${Level.enabled() ? 'checked' : ''} />
|
||||
</label>
|
||||
${EQ.isIOS() ? `<label class="set-row">
|
||||
<span>
|
||||
Live sound processing
|
||||
<small>Applies the equalizer and levelling to streamed songs too. iOS stops this sound when the screen locks — saved songs are unaffected.</small>
|
||||
</span>
|
||||
<input id="setLiveAudioIOS" type="checkbox" ${data.settings.liveAudioIOS ? 'checked' : ''} />
|
||||
</label>` : ''}
|
||||
<label class="set-row">
|
||||
<span>Default volume</span>
|
||||
<input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
|
||||
@@ -9519,7 +9689,14 @@ async function renderSettings() {
|
||||
data.settings.levelVolume = e.target.checked;
|
||||
persist();
|
||||
if (current && current.meta) Level.onTrack(current.meta); else EQ.setLevel(0);
|
||||
if (e.target.checked && EQ.isIOS()) toast('Volume levelling on — sound stops when the screen locks', { duration: 4000 });
|
||||
EqRender.scheduleAll();
|
||||
});
|
||||
if ($('setLiveAudioIOS')) $('setLiveAudioIOS').addEventListener('change', (e) => {
|
||||
data.settings.liveAudioIOS = e.target.checked;
|
||||
persist();
|
||||
EQ.apply();
|
||||
if (current && current.meta) Level.onTrack(current.meta);
|
||||
if (e.target.checked) toast('Live sound processing on — streamed songs go silent when the screen locks', { duration: 4500 });
|
||||
});
|
||||
|
||||
|
||||
@@ -12249,6 +12426,8 @@ async function boot() {
|
||||
updateBottomChrome();
|
||||
// Continue saves that were interrupted (closed app, dropped connection).
|
||||
if (WEB) SaveQueue.start();
|
||||
// iPhone: bring saved songs' EQ/levelled copies up to date (one at a time).
|
||||
if (WEB) setTimeout(() => EqRender.scheduleAll(), 15000);
|
||||
// Not awaited — artwork backfill must never delay first paint.
|
||||
warmOfflineThumbs();
|
||||
|
||||
|
||||
@@ -185,7 +185,29 @@ async function resumableSave(videoId, url, target) {
|
||||
self.postMessage({ type: 'done', ext: target.ext || 'mp4', sha256, expectedSha, size: target.size });
|
||||
}
|
||||
|
||||
// Write a whole file into an OPFS sub-directory (sync access handle: works on
|
||||
// iOS Safari, where main-thread createWritable is missing). Used for the
|
||||
// iPhone EQ/levelling renders (EqRender in app.js).
|
||||
async function writeFile(dirName, name, buffer) {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const dir = await root.getDirectoryHandle(dirName, { create: true });
|
||||
const h = await dir.getFileHandle(name, { create: true });
|
||||
const a = await h.createSyncAccessHandle();
|
||||
try {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
a.truncate(0);
|
||||
a.write(bytes, { at: 0 });
|
||||
a.truncate(bytes.byteLength);
|
||||
a.flush();
|
||||
} finally { a.close(); }
|
||||
}
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
if (e.data && e.data.op === 'write') {
|
||||
try { await writeFile(e.data.dir, e.data.name, e.data.buffer); self.postMessage({ type: 'written' }); }
|
||||
catch (err) { self.postMessage({ type: 'error', error: err && err.message ? err.message : String(err) }); }
|
||||
return;
|
||||
}
|
||||
const { videoId, url, resumable } = e.data || {};
|
||||
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user