Icon-only playlist header and filter-row actions, one-row transport, resumable update download

- Playlist title wraps within 90% of the width; rename and share are icons beside it,
  and tapping the title opens the rename box showing the full name
- Offline, pin, select and delete are icon buttons to the right of the filter input
- Transport is one row again: prev, back 10, play, forward 10, next
- Update download stages each file as it arrives so a retry only fetches what is
  missing, keeps 4 requests in flight and allows 5 minutes
This commit is contained in:
Jonathan Sykes
2026-10-02 20:59:51 +08:00
parent 236c129b90
commit 985063648f
6 changed files with 266 additions and 54 deletions

View File

@@ -52,6 +52,7 @@ function fakeCaches(initial) {
return {
store,
async keys() { return [...store.keys()]; },
async delete(name) { return store.delete(name); },
async open(name) {
if (!store.has(name)) store.set(name, new Map());
const m = store.get(name);
@@ -59,6 +60,7 @@ function fakeCaches(initial) {
async keys() { return [...m.keys()].map((url) => ({ url })); },
async put(url, res) { m.set(url, res.body); },
async delete(req) { return m.delete(req.url); },
async match(url) { return m.has(url) ? res(m.get(url)) : undefined; },
};
},
};
@@ -154,7 +156,58 @@ test('refreshShellInPlace times out instead of hanging on a stalled request', as
test('refreshShellInPlace with no shell caches (uncontrolled page) is a no-op', async () => {
const r = await refreshShellInPlace({ cachesApi: fakeCaches({}), isShellCache, fetchFn: async () => { throw new Error('no fetch expected'); } });
assert.deepStrictEqual(r, { refreshed: 0, caches: 0 });
assert.deepStrictEqual(r, { refreshed: 0, caches: 0, resumed: 0 });
});
test('refreshShellInPlace resumes: files that arrived before a failure are not fetched again', async () => {
const cachesApi = fakeCaches({
'ytplayer-old': { [`${ORIGIN}/`]: 'old-index', [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/styles.css`]: 'old-css' },
});
let cssUp = false;
const fetched = [];
const fetchFn = async (url) => {
fetched.push(new URL(url).pathname);
return url.includes('/styles.css') && !cssUp ? res('gateway timeout', 504) : res('NEW ' + new URL(url).pathname);
};
await assert.rejects(refreshShellInPlace({ cachesApi, isShellCache, fetchFn, stagingKey: 'B1' }), /styles\.css/);
assert.deepStrictEqual([...cachesApi.store.get('ytplayer-old').values()], ['old-index', 'old-app', 'old-css'], 'live shell untouched');
fetched.length = 0;
cssUp = true;
const r = await refreshShellInPlace({ cachesApi, isShellCache, fetchFn, stagingKey: 'B1' });
assert.deepStrictEqual(fetched, ['/styles.css'], 'only the missing file is fetched on the retry');
assert.strictEqual(r.resumed, 2);
assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/app.js`), 'NEW /app.js');
assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/styles.css`), 'NEW /styles.css');
assert.ok(![...cachesApi.store.keys()].some((k) => k.startsWith('ytp-staging-')), 'staging removed after success');
});
test('refreshShellInPlace never reuses files staged for a different build', async () => {
const cachesApi = fakeCaches({
'ytplayer-old': { [`${ORIGIN}/app.js`]: 'old-app' },
'ytp-staging-OLDBUILD': { [`${ORIGIN}/app.js`]: 'STALE' },
});
const r = await refreshShellInPlace({ cachesApi, isShellCache, stagingKey: 'NEWBUILD', fetchFn: async () => res('FRESH') });
assert.strictEqual(r.resumed, 0);
assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/app.js`), 'FRESH');
assert.ok(!cachesApi.store.has('ytp-staging-OLDBUILD'));
});
test('refreshShellInPlace keeps at most `concurrency` downloads in flight', async () => {
const files = {};
for (let i = 0; i < 12; i++) files[`${ORIGIN}/f${i}.js`] = 'old';
const cachesApi = fakeCaches({ 'ytplayer-old': files });
let inFlight = 0, peak = 0;
await refreshShellInPlace({
cachesApi, isShellCache, concurrency: 4,
fetchFn: async () => {
inFlight++; peak = Math.max(peak, inFlight);
await new Promise((r) => setImmediate(r));
inFlight--;
return res('NEW');
},
});
assert.strictEqual(peak, 4);
assert.ok([...cachesApi.store.get('ytplayer-old').values()].every((v) => v === 'NEW'));
});
test('applyUpdate refreshes first, then activates the waiting worker, then reloads once on controllerchange', async () => {