Music on iPhone: keep 3-5 songs ready (size-capped), skip a song that will not load instead of sticking on Buffering, tidy the album buttons, lock-screen art in more sizes, and accept the lookups and song-cache settings on the server

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
This commit is contained in:
Claude committed 2026-10-28 02:56:18 +00:00
1 parent 1a797c972e
commit d025e250fe
10 files changed
+43 -21

No files matched your search

+19 -15
View File
@@ -22,9 +22,12 @@ const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSear
// so changing songs is instant and a slow or lost connection does not stop the music.
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
const tc = { m: new Map(), ctl: null, busy: false };
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); // (long audio, such as audiobooks, is streamed, not saved whole)
const tcCount = () => Math.min(5, Math.max(3, +store.get('song_cache', 5) || 5)); // songs kept ready, counting the one playing (Settings: 3 to 5)
const TC_BYTES = 120e6; // however many songs, never hold more than this in memory (a lossless album could otherwise fill a phone)
const tcWant = () => { const w = pl.q.slice(Math.max(pl.i, 0), Math.max(pl.i, 0) + tcCount()).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); return w; }; // (the one playing first, so a dropped connection cannot stop it, then the ones coming up; long audio, such as audiobooks, is streamed, not saved whole)
const tcBytes = () => { let n = 0; for (const v of tc.m.values()) n += v.size || 0; return n; };
function tcTrim() {
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), Math.max(pl.i, 0) + tcCount()).map(t => t.src));
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
}
async function tcFetch(src, signal) {
@@ -32,7 +35,7 @@ async function tcFetch(src, signal) {
try {
const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
const b = await r.blob(); // throws if the connection drops part way
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b) });
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b), size: b.size });
return;
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
}
@@ -42,7 +45,8 @@ async function tcPump() {
try {
for (;;) {
tcTrim();
const src = tcWant().find(x => !tc.m.has(x)); if (!src) break;
const want = tcWant(), src = want.find(x => !tc.m.has(x)); if (!src) break;
if (tcBytes() > TC_BYTES && want.indexOf(src) > 1) break; // (the song playing and the next one are always kept; the ones after wait while the cache is full)
tc.ctl = new AbortController(); tc.cur = src;
await tcFetch(src, tc.ctl.signal);
if (!tc.ctl.signal.aborted && !tc.m.has(src)) break; // gave up on this one; try again on the next trigger
@@ -58,7 +62,7 @@ pl.retries = 0; pl.buf = false;
// Show the new track at once, but wait a moment before fetching it so quickly tapping Next several times only loads the last one.
function loadCur(delay = 220) {
const t = cur(); if (!t) return;
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false; pl.recovering = false;
const tok = ++loadTok; pl.buf = true;
if (delay) pl.a.pause();
try { // (a bug in any screen's refresh must never stop the music from starting)
@@ -80,15 +84,15 @@ function loadCur(delay = 220) {
}
function setMeta(t) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [96, 128, 256, 384, 512].map(n => ({ src: px.art(t.thumb, n), sizes: n + 'x' + n })) });
}
// If the music stalls, switch to the fully downloaded copy (if there is one) or reload the stream from where it stopped.
function recover(now) {
clearTimeout(stallTimer); const t = cur(), a = pl.a;
if (!t || a.paused) return;
if (!t || (a.paused && !pl.recovering)) return;
stallTimer = setTimeout(() => {
const forced = pl.forceRecover; pl.forceRecover = false;
if (a.paused || (!forced && a.readyState >= 3 && !a.error)) return;
if ((a.paused && !pl.recovering) || (!forced && a.readyState >= 3 && !a.error)) return; // (a reload makes the element report "paused" by itself, which must not stop us trying again)
const hit = tc.m.get(t.src), pos = a.currentTime;
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
if (bad && !hit) {
@@ -101,10 +105,10 @@ function recover(now) {
if (!pl.gaveUp) { pl.gaveUp = true; pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); }
stallTimer = setTimeout(() => { pl.retries = 0; recover(true); }, 15000); return; }
if (!hit || a.src !== hit.url) pl.retries++;
a.src = want;
pl.recovering = true; a.src = want;
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
recover();
}, now ? 300 : 4000);
}, now ? 300 : a.error ? 1200 : 4000); // (a file that plainly will not load is not waited on for long)
}
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); }
const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); npQueueCount(); toast('Playing next'); };
@@ -156,7 +160,7 @@ function advance(auto) {
}
const next = () => advance(false);
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else if (pl.i > 0) { pl.i--; loadCur(); } };
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); return pl.a.paused ? pl.a.play() : pl.a.pause(); };
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); pl.recovering = false; return pl.a.paused ? pl.a.play() : pl.a.pause(); };
pl.a.preload = 'auto';
// Tell Plex what is playing, so it shows under Now Playing / Activity in the Plex dashboard (as "NE Hub", under the signed-in Plex account).
let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
@@ -183,14 +187,14 @@ pl.a.addEventListener('timeupdate', () => {
});
addEventListener('pagehide', saveState);
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; pl.gaveUp = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
pl.a.addEventListener('playing', () => { pl.recovering = false; tlReport('playing'); pl.buf = false; pl.retries = 0; pl.gaveUp = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
pl.a.addEventListener('pause', () => { if (pl.recovering) return; if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
pl.a.addEventListener('canplaythrough', () => tcKick());
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; syncUI(); recover(); });
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; pl.recovering = true; syncUI(); recover(); });
if ('mediaSession' in navigator) {
const ms = navigator.mediaSession;
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => pl.a.pause());
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => { pl.recovering = false; pl.a.pause(); });
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
try { ms.setActionHandler('seekbackward', d => { pl.a.currentTime = Math.max(0, pl.a.currentTime - (d.seekOffset || 10)); }); ms.setActionHandler('seekforward', d => { pl.a.currentTime += d.seekOffset || 10; }); } catch {}