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:
10 files changed
+43
-21
No files matched your search
+19
-15
@@ -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 {}
|
||||
|
||||
Reference in new issue
Block a user