From c282c4964e26efd18fd78c15e3f3e532dc899af4 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:05:18 -0400 Subject: [PATCH 1/4] Add the Up Next widget for Sonarr, Radarr and Lidarr Lists the next episodes, films and albums from up to six apps, soonest first, with deep links to each item. Sonarr series records are fetched per series and cached, so the calendar reply stays small. Include Unmonitored is cosmetic, so changing it keeps every app key. --- README.md | 1 + api/src/widgets.js | 1 + api/test/config-secrets.test.js | 61 ++++ api/test/upnext-data.test.js | 372 ++++++++++++++++++++ ui/admin/index.html | 2 +- ui/index.html | 2 +- ui/js/admin-app-form.js | 8 +- ui/js/admin-auth.js | 2 +- ui/js/admin-color-control.js | 6 +- ui/js/admin-list.js | 8 +- ui/js/admin-settings.js | 4 +- ui/js/admin-shared.js | 2 +- ui/js/admin-widget-form.js | 8 +- ui/js/admin.js | 16 +- ui/js/dashboard.js | 2 +- ui/js/icon-set.js | 12 + ui/js/listbox.js | 2 +- ui/js/widget-config-form.js | 6 +- ui/js/widget-glyphs.js | 3 +- ui/js/widget-toolbox.js | 3 +- ui/test/upnext-logic.test.mjs | 49 +++ ui/widgets/backup/backup.html | 2 +- ui/widgets/books/index.html | 2 +- ui/widgets/clock/analog.html | 2 +- ui/widgets/clock/digital.html | 2 +- ui/widgets/connections/connections-map.html | 2 +- ui/widgets/connections/connections-vpn.html | 2 +- ui/widgets/dashboard-switch/index.html | 2 +- ui/widgets/disk-health/index.html | 2 +- ui/widgets/dns/index.html | 2 +- ui/widgets/downloads/index.html | 4 +- ui/widgets/github/contributions.html | 2 +- ui/widgets/github/pullrequests.html | 2 +- ui/widgets/nowplaying/index.html | 2 +- ui/widgets/system-summary/index.html | 2 +- ui/widgets/upnext/data.js | 160 +++++++++ ui/widgets/upnext/demo.js | 64 ++++ ui/widgets/upnext/i18n/de.json | 36 ++ ui/widgets/upnext/i18n/en.json | 36 ++ ui/widgets/upnext/i18n/es.json | 36 ++ ui/widgets/upnext/i18n/fa.json | 36 ++ ui/widgets/upnext/i18n/fr.json | 36 ++ ui/widgets/upnext/i18n/zh-Hans.json | 36 ++ ui/widgets/upnext/index.html | 236 +++++++++++++ ui/widgets/upnext/upnext-items.js | 191 ++++++++++ ui/widgets/upnext/upnext-logic.js | 38 ++ ui/widgets/upnext/widget.json | 105 ++++++ ui/widgets/weather/index.html | 4 +- 48 files changed, 1561 insertions(+), 53 deletions(-) create mode 100644 api/test/upnext-data.test.js create mode 100644 ui/test/upnext-logic.test.mjs create mode 100644 ui/widgets/upnext/data.js create mode 100644 ui/widgets/upnext/demo.js create mode 100644 ui/widgets/upnext/i18n/de.json create mode 100644 ui/widgets/upnext/i18n/en.json create mode 100644 ui/widgets/upnext/i18n/es.json create mode 100644 ui/widgets/upnext/i18n/fa.json create mode 100644 ui/widgets/upnext/i18n/fr.json create mode 100644 ui/widgets/upnext/i18n/zh-Hans.json create mode 100644 ui/widgets/upnext/index.html create mode 100644 ui/widgets/upnext/upnext-items.js create mode 100644 ui/widgets/upnext/upnext-logic.js create mode 100644 ui/widgets/upnext/widget.json diff --git a/README.md b/README.md index 65927023..8e64a840 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Widgets and the services they read: - **Disk health**: TrueNAS, Unraid, Scrutiny - **Backup**: Duplicati, Kopia, Proxmox Backup Server, Backrest - **Downloads**: qBittorrent, Transmission, Deluge, SABnzbd, NZBGet +- **Up Next**: the next episodes, films and albums from Sonarr, Radarr and Lidarr - **Connections**: Gluetun, Psiphon Conduit, Netbird, Plausible, Umami - **Dashboard switch**: links to your other Stackyard dashboards diff --git a/api/src/widgets.js b/api/src/widgets.js index 5113c847..fa3075c7 100644 --- a/api/src/widgets.js +++ b/api/src/widgets.js @@ -26,6 +26,7 @@ const VALID_GLYPHS = new Set([ 'merge', 'panels', 'parcel', + 'calendar', ]); const VALID_FIELDTYPES = new Set([ 'text', diff --git a/api/test/config-secrets.test.js b/api/test/config-secrets.test.js index bd501b6a..7e4828b4 100644 --- a/api/test/config-secrets.test.js +++ b/api/test/config-secrets.test.js @@ -417,3 +417,64 @@ test('a login password is not refilled for another username', () => { preserveAllSecrets(incoming, stored); assert.equal(incoming.items[0].monitoring.activity.login.password, ''); }); + +function upNextWith(sources, extra = {}) { + return { items: [{ id: 'u1', type: 'widget', widgetType: 'upnext', widgetConfig: { sources, ...extra } }] }; +} +const SONARR = { app: 'sonarr', url: 'http://sonarr:8989', apiKey: 'SON', skipTls: false }; +const RADARR = { + app: 'radarr', + url: 'http://radarr:7878', + apiKey: 'RAD', + skipTls: false, + releases: ['cinema', 'digital'], +}; +const sentAs = (row, savedRow) => { + const { apiKey, ...rest } = row; + return { ...rest, apiKeySet: true, savedRow }; +}; + +test('Up Next: switching unmonitored, release types or a Link URL keeps every app key', () => { + const next = upNextWith( + [sentAs({ ...SONARR, href: 'https://tv.example' }, 0), sentAs({ ...RADARR, releases: ['physical'] }, 1)], + { unmonitored: true }, + ); + const { withheld } = preserveAllSecrets(next, upNextWith([SONARR, RADARR])); + assert.deepEqual(withheld, []); + assert.deepEqual( + next.items[0].widgetConfig.sources.map(r => r.apiKey), + ['SON', 'RAD'], + ); +}); + +test('Up Next: removing the first app keeps the second app its own key', () => { + const next = upNextWith([sentAs(RADARR, 1)]); + const { withheld } = preserveAllSecrets(next, upNextWith([SONARR, RADARR])); + assert.deepEqual(withheld, []); + assert.equal(next.items[0].widgetConfig.sources[0].apiKey, 'RAD'); +}); + +test('Up Next: an app whose address or app type changed loses only its own key', () => { + for (const change of [{ url: 'http://elsewhere:8989' }, { app: 'lidarr' }, { skipTls: true }]) { + const next = upNextWith([sentAs({ ...SONARR, ...change }, 0), sentAs(RADARR, 1)]); + const { withheld } = preserveAllSecrets(next, upNextWith([SONARR, RADARR])); + assert.deepEqual(withheld, [{ id: 'u1', label: 'u1' }], JSON.stringify(change)); + assert.deepEqual( + next.items[0].widgetConfig.sources.map(r => r.apiKey), + [undefined, 'RAD'], + JSON.stringify(change), + ); + } +}); + +test('Up Next: keys never reach the browser', () => { + const sent = scrubAllSecrets(structuredClone(upNextWith([SONARR, RADARR]))); + const rows = sent.items[0].widgetConfig.sources; + assert.deepEqual( + rows.map(r => [r.apiKey, r.apiKeySet]), + [ + [undefined, true], + [undefined, true], + ], + ); +}); diff --git a/api/test/upnext-data.test.js b/api/test/upnext-data.test.js new file mode 100644 index 00000000..f67f57ea --- /dev/null +++ b/api/test/upnext-data.test.js @@ -0,0 +1,372 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const widget = path.join(__dirname, '..', '..', 'ui', 'widgets', 'upnext'); +const items = require(path.join(widget, 'upnext-items.js')); +const data = require(path.join(widget, 'data.js')); +const { errorParts } = require('../test-support/widget-ctx'); +const { normalizeBase } = require('../src/widget-data'); +const { serviceLink } = require('../../ui/js/service-link.js'); + +const HOUR = 3600 * 1000; +const DAY = 24 * HOUR; +const NOW = Date.parse('2026-10-25T18:00:00Z'); + +function ctxFor(config, route) { + const calls = []; + const ctx = { + config, + normalizeBase, + serviceLink, + fetchJSON: async (url, opts = {}) => { + calls.push({ url, opts }); + return route(url, opts); + }, + ...errorParts(), + }; + return { ctx, calls }; +} + +const idOf = title => [...title].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 100000, 7) + 1; +const ep = (title, at, season, episode, extra = {}) => ({ + seriesId: idOf(title), + series: { title, titleSlug: title.toLowerCase().replace(/ /g, '-'), runtime: 50 }, + airDateUtc: at, + seasonNumber: season, + episodeNumber: episode, + title: 'Name ' + episode, + ...extra, +}); + +const sonarrServe = episodes => url => { + const one = /\/api\/v3\/series\/(\d+)$/.exec(url); + if (one) { + const e = episodes.find(x => x.seriesId === Number(one[1])); + return e ? { status: 200, data: e.series } : { status: 404, data: {} }; + } + if (url.includes('/api/v3/calendar')) return { status: 200, data: episodes.map(({ series, ...e }) => e) }; + throw new Error('unexpected ' + url); +}; + +test('Sonarr: an episode keeps its time, code and name; TBA names are dropped', () => { + const [a, b] = items.sonarrItems([ + ep('Northbound', '2026-10-25T21:00:00Z', 2, 4), + ep('Fieldwork', '2026-10-26T20:00:00Z', 1, 8, { title: 'TBA' }), + ]); + assert.equal(a.at, '2026-10-25T21:00:00.000Z'); + assert.deepEqual([a.season, a.episode, a.name, a.path], [2, 4, 'Name 4', '/series/northbound']); + assert.equal(a.until, Date.parse('2026-10-25T21:50:00Z'), 'ends after the series runtime'); + assert.equal(b.name, ''); +}); + +test('Sonarr: a season dropped at once is one row with an episode range', () => { + const t = '2026-10-30T07:00:00Z'; + const out = items.sonarrItems( + [1, 2, 3, 4].map(n => ep('Small Hours', t, 3, n, n === 4 ? { finaleType: 'season' } : {})), + ); + assert.equal(out.length, 1); + assert.deepEqual( + [out[0].episode, out[0].lastEpisode, out[0].name, out[0].finale, out[0].premiere], + [1, 4, '', 'season', true], + ); +}); + +test('Sonarr: a daily show drops its date-based code and name', () => { + const [a, b] = items.sonarrItems([ + ep('Quiz Hour', '2026-10-26T23:00:00Z', 2026, 178, { + series: { title: 'Quiz Hour', seriesType: 'daily' }, + title: 'Mon, Oct 26, 2026', + }), + ep('Quiz Hour', '2026-10-27T23:00:00Z', 2026, 179, { series: { title: 'Quiz Hour' } }), + ]); + assert.deepEqual([a.season, a.episode, a.name, a.premiere], [null, null, '', false]); + assert.notEqual(a.at, b.at, 'two daily episodes stay two rows'); + assert.deepEqual([b.season, b.name], [null, ''], 'a season numbered by year is date-based too'); + assert.equal( + items.sonarrItems([ep('Talk', '2026-10-27T23:00:00Z', 3, 9, { title: 'October 27, 2026' })])[0].name, + '', + ); +}); + +test('Sonarr: specials are not premieres, and rows with no series or time are skipped', () => { + const out = items.sonarrItems([ + ep('Northbound', '2026-10-25T21:00:00Z', 0, 1), + { airDateUtc: '2026-10-25T21:00:00Z', seasonNumber: 1, episodeNumber: 1 }, + ep('Fieldwork', 'not a date', 1, 1), + null, + 'x', + ]); + assert.equal(out.length, 1); + assert.equal(out[0].premiere, false); +}); + +test('Radarr: each selected release date in the window is a day row, read as a calendar day', () => { + const m = { + title: 'Glass Harbor', + titleSlug: 'glass-harbor-2026', + year: 2026, + inCinemas: '2026-09-01T00:00:00Z', + digitalRelease: '2026-10-27T00:00:00Z', + physicalRelease: '2026-11-20T00:00:00Z', + }; + const from = NOW - DAY; + const to = NOW + 14 * DAY; + const all = items.radarrItems([m], from, to); + assert.deepEqual( + all.map(i => [i.release, i.day, i.at]), + [['digital', '2026-10-27', null]], + ); + assert.equal(all[0].path, '/movie/glass-harbor-2026'); + assert.deepEqual( + items.radarrItems([m], from, NOW + 60 * DAY, ['physical']).map(i => i.release), + ['physical'], + ); + assert.deepEqual(items.radarrItems([m], from, to, []), [], 'no release types selected shows no films'); +}); + +test('Lidarr: albums carry artist and type, and link by album id', () => { + const [a] = items.lidarrItems([ + { + title: 'Low Tide', + releaseDate: '2026-10-30T00:00:00Z', + albumType: 'Album', + foreignAlbumId: 'abc', + artist: { artistName: 'Marrow Lane' }, + }, + ]); + assert.deepEqual( + [a.kind, a.day, a.artist, a.albumType, a.path], + ['album', '2026-10-30', 'Marrow Lane', 'album', '/album/abc'], + ); + assert.equal(items.lidarrItems([{ title: 'x', releaseDate: '', albumType: 'Broadcast' }]).length, 0); +}); + +test('merge: drops what has ended, removes a second instance’s copy, sorts and caps', () => { + const s1 = items.sonarrItems([ + ep('Northbound', '2026-10-25T21:00:00Z', 2, 4), + ep('Old', '2026-10-25T10:00:00Z', 1, 1), + ]); + const s2 = items.sonarrItems([ep('Northbound', '2026-10-25T21:00:00Z', 2, 4)]); + const films = items.lidarrItems([ + { title: 'Low Tide', releaseDate: '2026-10-25T00:00:00Z', artist: { artistName: 'A' } }, + ]); + const out = items.mergeItems([s1, s2, films], NOW); + assert.deepEqual( + out.map(i => i.title), + ['Low Tide', 'Northbound'], + ); + assert.ok(!('key' in out[0]) && !('until' in out[0])); + assert.equal(out[1].ends, '2026-10-25T21:50:00.000Z'); + const many = items.sonarrItems( + Array.from({ length: 30 }, (_, n) => ep('S' + n, new Date(NOW + (n + 1) * HOUR).toISOString(), 1, 2)), + ); + assert.equal(items.mergeItems([many], NOW).length, items.MAX_ITEMS); +}); + +test('data: reads every app with its key, merges, and links each row to its page', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const many = Array.from({ length: 9 }, (_, n) => ep('Show ' + n, new Date(NOW + (n + 1) * DAY).toISOString(), 1, 2)); + const { ctx, calls } = ctxFor( + { + sources: [ + { app: 'sonarr', url: 'sonarr.lan:8989', apiKey: 'k1' }, + { app: 'radarr', url: 'http://radarr.lan:7878/', apiKey: 'k2', href: 'https://films.example' }, + ], + }, + url => { + if (url.includes(':8989/')) return sonarrServe(many)(url); + if (url.includes(':7878/api/v3/calendar')) + return { + status: 200, + data: [{ title: 'Glass Harbor', titleSlug: 'glass-harbor', digitalRelease: '2026-10-27T00:00:00Z' }], + }; + throw new Error('unexpected ' + url); + }, + ); + const out = await data(ctx); + const calendars = calls.filter(c => c.url.includes('/calendar')); + assert.equal(calendars.length, 2, 'enough rows in 14 days: no second, longer read'); + assert.equal(calendars[0].opts.headers['X-Api-Key'], 'k1'); + assert.doesNotMatch(calendars[0].url, /includeSeries|unmonitored/); + assert.equal( + calls.filter(c => c.url.includes('/api/v3/series/')).length, + 9, + 'one lookup per series that can reach the list', + ); + assert.equal(out.days, 14); + assert.deepEqual(out.missing, []); + const film = out.items.find(i => i.kind === 'film'); + assert.equal(film.link, 'https://films.example/movie/glass-harbor'); + assert.ok(!('path' in film)); + assert.equal(out.items[0].link, 'http://sonarr.lan:8989/series/show-0'); +}); + +test('data: a quiet fortnight reads 90 days ahead', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const { ctx, calls } = ctxFor( + { sources: [{ app: 'lidarr', url: 'lidarr.lan', apiKey: 'k' }], unmonitored: true }, + () => ({ status: 200, data: [] }), + ); + const out = await data(ctx); + assert.equal(calls.length, 2); + assert.match(calls[0].url, /\/api\/v1\/calendar\?.*unmonitored=true.*includeArtist=true/); + const end = new URL(calls[1].url).searchParams.get('end'); + assert.equal(Date.parse(end), NOW + 90 * DAY); + assert.equal(out.days, 90); + assert.deepEqual(out.items, []); +}); + +test('data: one app failing leaves the rest and names it; all failing reports the first', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const sources = [ + { app: 'sonarr', url: 'sonarr.lan', apiKey: 'k' }, + { app: 'lidarr', url: 'lidarr.lan', apiKey: 'bad' }, + ]; + const { ctx } = ctxFor({ sources }, url => { + if (url.includes('lidarr')) return { status: 401, data: {} }; + return sonarrServe([ep('Northbound', '2026-10-25T21:00:00Z', 2, 4)])(url); + }); + const out = await data(ctx); + assert.deepEqual(out.missing, [{ app: 'lidarr', kind: 'auth' }]); + assert.equal(out.items.length, 1); + + const down = ctxFor({ sources }, () => { + throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }); + }); + await assert.rejects(data(down.ctx), /refused/); +}); + +test('data: a missing app, URL or key, or a key that cannot be a header, is not set up', async () => { + for (const config of [ + {}, + { sources: [{ app: 'plex', url: 'x', apiKey: 'k' }] }, + { sources: [{ app: 'sonarr', url: '', apiKey: 'k' }] }, + { sources: [{ app: 'sonarr', url: 'x', apiKey: '' }] }, + { sources: [{ app: 'sonarr', url: 'x', apiKey: 'a\r\nb' }] }, + ]) { + const { ctx, calls } = ctxFor(config, () => ({ status: 200, data: [] })); + await assert.rejects(data(ctx), e => e.kind === 'invalid', JSON.stringify(config)); + assert.equal(calls.length, 0); + } +}); + +test('data: an HTML or object reply is not a calendar', async () => { + const { ctx } = ctxFor({ sources: [{ app: 'radarr', url: 'r', apiKey: 'k' }] }, () => ({ + status: 200, + data: '', + })); + await assert.rejects(data(ctx), /not a calendar/); +}); + +test('Sonarr: only the series that can reach the list are looked up, soonest first', () => { + const eps = [ + ep('Ended', '2026-10-25T10:00:00Z', 1, 1), + ...Array.from({ length: 20 }, (_, n) => ep('S' + n, new Date(NOW + (n + 1) * HOUR).toISOString(), 1, 1)), + { ...ep('S0', new Date(NOW + 30 * HOUR).toISOString(), 1, 2) }, + { seriesId: 'x', airDateUtc: new Date(NOW + HOUR).toISOString() }, + ]; + const ids = items.sonarrSeriesIds(eps, NOW, 12); + assert.equal(ids.length, 12); + assert.equal(ids[0], idOf('S0')); + assert.ok(!ids.includes(idOf('Ended')), 'an episode that has ended needs no series'); +}); + +test('data: series lookups are cached per server, and a series that is gone is skipped', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const eps = [ep('Kept', '2026-10-25T21:00:00Z', 1, 2), ep('Gone', '2026-10-25T22:00:00Z', 1, 3)]; + const serve = sonarrServe(eps); + const route = url => (url.endsWith('/series/' + idOf('Gone')) ? { status: 404, data: {} } : serve(url)); + const first = ctxFor({ sources: [{ app: 'sonarr', url: 'cache.lan', apiKey: 'k' }] }, route); + const out = await data(first.ctx); + assert.deepEqual( + out.items.map(i => i.title), + ['Kept'], + ); + const again = ctxFor({ sources: [{ app: 'sonarr', url: 'cache.lan', apiKey: 'k' }] }, route); + await data(again.ctx); + assert.equal(again.calls.filter(c => c.url.endsWith('/series/' + idOf('Kept'))).length, 0, 'served from the cache'); +}); + +test('data: the card links to the first app calendar a browser can open', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const { ctx } = ctxFor( + { + sources: [ + { app: 'sonarr', url: 'http://sonarr:8989', apiKey: 'k' }, + { app: 'radarr', url: 'http://radarr.lan:7878', apiKey: 'k' }, + ], + }, + url => (url.includes('8989') ? sonarrServe([])(url) : { status: 200, data: [] }), + ); + const out = await data(ctx); + assert.equal(out.calendar, 'http://radarr.lan:7878/calendar', 'a bare container name is not a browser address'); +}); + +test('worst case: a large calendar is cut to the list without a series lookup per episode', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const eps = Array.from({ length: 20000 }, (_, n) => + ep('Show ' + (n % 400), new Date(NOW + (n + 1) * 60000).toISOString(), 1 + (n % 9), 1 + (n % 30)), + ); + const { ctx, calls } = ctxFor({ sources: [{ app: 'sonarr', url: 'big.lan', apiKey: 'k' }] }, sonarrServe(eps)); + const out = await data(ctx); + assert.equal(out.items.length, items.MAX_ITEMS); + assert.ok(calls.filter(c => c.url.includes('/series/')).length <= items.MAX_ITEMS); +}); + +test('worst case: a timeout or refusal names that app, and a failed longer read keeps the shorter one', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + let radarrReads = 0; + const { ctx } = ctxFor( + { + sources: [ + { app: 'radarr', url: 'slow.lan', apiKey: 'k' }, + { app: 'lidarr', url: 'down.lan', apiKey: 'k' }, + ], + }, + url => { + if (url.includes('down.lan')) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }); + radarrReads++; + if (radarrReads > 1) throw Object.assign(new Error('slow'), { code: 'ETIMEDOUT' }); + return { status: 200, data: [{ title: 'Glass Harbor', digitalRelease: '2026-10-27T00:00:00Z' }] }; + }, + ); + const out = await data(ctx); + assert.deepEqual(out.missing, [{ app: 'lidarr', kind: 'network' }]); + assert.equal(out.days, 90); + assert.deepEqual( + out.items.map(i => i.title), + ['Glass Harbor'], + 'the 14-day read stands in for the failed 90-day one', + ); +}); + +test('bad config: more than six apps are cut to six, and unknown apps are ignored', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const sources = [ + { app: 'plex', url: 'x', apiKey: 'k' }, + ...Array.from({ length: 9 }, (_, n) => ({ app: 'radarr', url: `r${n}.lan`, apiKey: 'k' })), + ]; + const { ctx, calls } = ctxFor({ sources }, () => ({ status: 200, data: [] })); + await data(ctx); + const hosts = new Set(calls.map(c => new URL(c.url).hostname)); + assert.equal(hosts.size, 5, 'six rows read, one of them unknown'); +}); + +test('bad data: wrong types in a reply never throw', () => { + const junk = [ + null, + 1, + 'x', + [], + {}, + { airDateUtc: 5 }, + { series: 'x', airDateUtc: '2026-10-25T21:00:00Z' }, + { title: {}, digitalRelease: 7 }, + ]; + assert.deepEqual(items.sonarrItems(junk), []); + assert.deepEqual(items.radarrItems(junk, 0, Infinity), []); + assert.deepEqual(items.lidarrItems(junk), []); + assert.deepEqual(items.sonarrSeriesIds(junk, NOW, 12), []); + assert.deepEqual(items.sonarrItems('nope'), []); +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 7a36a740..9172e0a3 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -318,6 +318,6 @@

General

- + diff --git a/ui/index.html b/ui/index.html index ddf74278..3e644c05 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index ae25b97b..17bd9de6 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -33,13 +33,13 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=a3e8bc29'; -import { createListbox } from '/js/listbox.js?v=9a579bea'; +} from '/js/admin-shared.js?v=1f6cf982'; +import { createListbox } from '/js/listbox.js?v=b4aef017'; import { MAX_LABELS } from '/js/badge-logic.js?v=85b1bc96'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=e2a0169e'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=75b15192'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; export function buildFolderForm(body, item) { const children = item?.children || []; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 45ed2b51..acf2f514 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=a3e8bc29'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=1f6cf982'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=a60b17ac'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 3345f740..a03bbbba 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,9 +1,9 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=a3e8bc29'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=1f6cf982'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=cfee1271'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; -import { createListbox } from '/js/listbox.js?v=9a579bea'; +import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; +import { createListbox } from '/js/listbox.js?v=b4aef017'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 8ca7f5ba..889a85b4 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -18,13 +18,13 @@ import { listGroup } from '/js/admin-drag-logic.js?v=1d1d553e'; import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=14bd8c6c'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { paintIcon, toast } from '/js/admin-shared.js?v=a3e8bc29'; +import { paintIcon, toast } from '/js/admin-shared.js?v=1f6cf982'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=cfee1271'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=2427cc08'; -import { widgetGlyph } from '/js/widget-glyphs.js?v=f0c89a0a'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { sizeLabel } from '/js/admin-widget-form.js?v=406af7a9'; +import { widgetGlyph } from '/js/widget-glyphs.js?v=68cc7695'; +import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; /** @type {{ openModal: (idx: number|null) => void, openFolderPicker: (appId: string|null, targetFolderId?: string|null) => void, diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 2e419b23..1ccfb679 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=a3e8bc29'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=1f6cf982'; import { socketProbeAdvice } from '/js/admin-error.js?v=a60b17ac'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -14,7 +14,7 @@ import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=cfee1271'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=32cb6aeb'; -import { renderColorControl } from '/js/admin-color-control.js?v=e2a0169e'; +import { renderColorControl } from '/js/admin-color-control.js?v=75b15192'; import { BACKDROP } from '/js/background.js?v=72db97ac'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index fafe38bb..da8ecf96 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -5,7 +5,7 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=a60b17ac'; import { formatList, formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { iconChain } from '/js/icons.js?v=5ac01d72'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; export const API = ''; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 6c46e455..d97679f7 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,12 +1,12 @@ import { state } from '/js/admin-state.js?v=064b9b85'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=a3e8bc29'; -import { createListbox } from '/js/listbox.js?v=9a579bea'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=8b444e40'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=1f6cf982'; +import { createListbox } from '/js/listbox.js?v=b4aef017'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=a99b1926'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=b3befae4'; import { t } from '/js/i18n.js?v=ed7c5565'; import { focusFirst, q, qi, qa } from '/js/utils.js?v=cfee1271'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/ui/js/admin.js b/ui/js/admin.js index 8f8085fd..daedd7ad 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=896b8d1d'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=6466f02d'; +} from '/js/admin-app-form.js?v=a27eed58'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=5bdfa3ba'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=a0568eab'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=143595bd'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=b3befae4'; import { afterImport, @@ -31,7 +31,7 @@ import { wireUnsplashKey, wireProxmox, applyHealthChecks, -} from '/js/admin-settings.js?v=5709194e'; +} from '/js/admin-settings.js?v=a04f44de'; import { apiGet, apiPost, @@ -45,17 +45,17 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=a3e8bc29'; +} from '/js/admin-shared.js?v=1f6cf982'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=2427cc08'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=406af7a9'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=9a579bea'; +import { createListbox } from '/js/listbox.js?v=b4aef017'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=88ae97cc'; import { clearSkipTls, convert, diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 4e57d4a1..37fe7e67 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -64,7 +64,7 @@ import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=88ae97cc'; import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43'; import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=eeafa4b5'; diff --git a/ui/js/icon-set.js b/ui/js/icon-set.js index f6ae09c9..b06bcaa3 100644 --- a/ui/js/icon-set.js +++ b/ui/js/icon-set.js @@ -240,6 +240,18 @@ export const ICONS = { ], }, nowplaying: { shapes: [rect(3, 3, 18, 18, T.R_LG), shape('M10 8v8l6-4z', 'accent', { rj: true })] }, + upnext: { + gap: 0, + shapes: [ + rect(2, 4, 20, 16, T.R_LG), + bar(5, 7, 3), + bar(10, 7, 9, 'accent'), + bar(5, 11, 3), + bar(10, 11, 7), + bar(5, 15, 3), + bar(10, 15, 8), + ], + }, system: { shapes: [line('M3 16 7 12 11 15 16 8'), dot(19, 6, T.NODE, 'accent')] }, weather: { inkMask: false, diff --git a/ui/js/listbox.js b/ui/js/listbox.js index 85808069..d14498ca 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -11,7 +11,7 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa } from '/js/utils.js?v=cfee1271'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index 12b94d04..a0b9f21f 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=a3e8bc29'; -import { createListbox } from '/js/listbox.js?v=9a579bea'; -import { renderColorControl } from '/js/admin-color-control.js?v=e2a0169e'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=1f6cf982'; +import { createListbox } from '/js/listbox.js?v=b4aef017'; +import { renderColorControl } from '/js/admin-color-control.js?v=75b15192'; import { seedCarried, applyOptionSet, diff --git a/ui/js/widget-glyphs.js b/ui/js/widget-glyphs.js index f4f4db5d..f66a5c0c 100644 --- a/ui/js/widget-glyphs.js +++ b/ui/js/widget-glyphs.js @@ -1,5 +1,5 @@ // @ts-check -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; /* Keys are the glyph names in widget.json files. Renaming one breaks that widget. */ /** @type {Record} */ @@ -16,6 +16,7 @@ const GLYPH_ICON = { merge: 'github', panels: 'switcher', parcel: 'downloads', + calendar: 'upnext', }; /** The glyph names a manifest may declare. */ diff --git a/ui/js/widget-toolbox.js b/ui/js/widget-toolbox.js index 2e8367a0..ee81f0f8 100644 --- a/ui/js/widget-toolbox.js +++ b/ui/js/widget-toolbox.js @@ -581,7 +581,8 @@ const PH_STYLE_ID = 'wt-ph-css'; const PH_CSS = '.wt-ph{display:inline-block;vertical-align:middle;flex:none;border-radius:4px;' + 'background:var(--wt-ph,rgba(235,235,245,0.16))}' + - 'html[data-theme="light"] .wt-ph{background:var(--wt-ph,rgba(60,60,67,0.12))}'; + 'html[data-theme="light"] .wt-ph{background:var(--wt-ph,rgba(60,60,67,0.12))}' + + '@media (forced-colors:active){.wt-ph{forced-color-adjust:none;background:GrayText}}'; /** A soft rounded shape that stands in for content still loading. Set --wt-ph on an ancestor to recolour it for a light surface. diff --git a/ui/test/upnext-logic.test.mjs b/ui/test/upnext-logic.test.mjs new file mode 100644 index 00000000..ab968b4a --- /dev/null +++ b/ui/test/upnext-logic.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { dayOffset, kindClass, localDay, visibleItems } from '../widgets/upnext/upnext-logic.js'; + +const at = (y, m, d, h = 12, min = 0) => new Date(y, m - 1, d, h, min); + +test('dayOffset counts local calendar days, not 24-hour spans', () => { + const now = at(2026, 10, 25, 23, 30); + assert.equal(dayOffset(at(2026, 10, 25, 0, 5), now), 0); + assert.equal(dayOffset(at(2026, 10, 26, 0, 5), now), 1); + assert.equal(dayOffset(at(2026, 11, 1, 9), now), 7); +}); + +test('a timed item stays until it ends; a day item for its whole local day', () => { + const now = at(2026, 10, 25, 21, 30); + const items = [ + { + kind: 'episode', + title: 'Airing', + at: at(2026, 10, 25, 21).toISOString(), + day: '', + ends: at(2026, 10, 25, 21, 50).toISOString(), + }, + { + kind: 'episode', + title: 'Over', + at: at(2026, 10, 25, 20).toISOString(), + day: '', + ends: at(2026, 10, 25, 20, 50).toISOString(), + }, + { kind: 'film', title: 'Today', at: null, day: localDay(now), ends: '' }, + { kind: 'album', title: 'Yesterday', at: null, day: localDay(at(2026, 10, 24)), ends: '' }, + { kind: 'film', title: 'Later', at: null, day: '2026-11-02', ends: '' }, + null, + { title: 3 }, + ]; + assert.deepEqual( + visibleItems(items, now).map(i => i.title), + ['Airing', 'Today', 'Later'], + ); + assert.deepEqual(visibleItems('nope', now), []); +}); + +test('kindClass maps every kind to a bar colour class', () => { + assert.equal(kindClass({ kind: 'film' }), 'film'); + assert.equal(kindClass({ kind: 'album' }), 'album'); + assert.equal(kindClass({ kind: 'episode' }), 'episode'); + assert.equal(kindClass({ kind: 'other' }), 'episode'); +}); diff --git a/ui/widgets/backup/backup.html b/ui/widgets/backup/backup.html index 07737427..8c0b5e13 100644 --- a/ui/widgets/backup/backup.html +++ b/ui/widgets/backup/backup.html @@ -122,7 +122,7 @@
+ + + +
+
    + +
    + + + diff --git a/ui/widgets/upnext/upnext-items.js b/ui/widgets/upnext/upnext-items.js new file mode 100644 index 00000000..d68c3feb --- /dev/null +++ b/ui/widgets/upnext/upnext-items.js @@ -0,0 +1,191 @@ +const DAY_MS = 24 * 3600 * 1000; +const MAX_ITEMS = 12; +const DEFAULT_RUNTIME_MIN = 60; +const DATE_ONLY = /^(\d{4}-\d{2}-\d{2})/; +const RELEASES = Object.freeze([ + ['inCinemas', 'cinema'], + ['digitalRelease', 'digital'], + ['physicalRelease', 'physical'], +]); + +const str = v => (typeof v === 'string' ? v.trim() : ''); +const list = v => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object' && !Array.isArray(x)) : []); +const whole = v => (Number.isInteger(v) && v >= 0 ? v : null); +const ms = v => { + const t = typeof v === 'string' ? Date.parse(v) : NaN; + return Number.isFinite(t) ? t : null; +}; +/* Radarr and Lidarr dates are calendar days sent as midnight UTC. Keep the day, + not the instant, or a reader west of UTC sees the day before. */ +const dayOf = v => { + const m = DATE_ONLY.exec(str(v)); + return m ? m[1] : ''; +}; +const dayStartMs = day => Date.parse(day + 'T00:00:00Z'); +const slugPath = (prefix, slug) => (slug ? `${prefix}/${encodeURIComponent(slug)}` : ''); + +/* "Mon, Oct 26, 2026" as an episode name. Engines parse loose text as dates too, + so only a date near the air date counts. */ +const namesItsDate = (name, at) => { + const d = Date.parse(name); + return Number.isFinite(d) && Math.abs(d - at) < 2 * DAY_MS; +}; + +/** One row. `at` is an ISO instant for a timed item, or null. `day` is the + calendar day for an item with no time. `until` is when it stops being next. */ +function makeItem({ kind, title, at = null, day = '', until, path = '', ...rest }) { + return { kind, title, at, day, until, path, ...rest }; +} + +/** The series of the soonest episodes still to come, at most `limit`, soonest first. + @param {unknown} episodes @param {number} now @param {number} limit @returns {number[]} */ +function sonarrSeriesIds(episodes, now, limit) { + const ids = []; + const coming = list(episodes) + .map(e => ({ id: e.seriesId, t: ms(e.airDateUtc), runtime: whole(e.runtime) || DEFAULT_RUNTIME_MIN })) + .filter(e => Number.isSafeInteger(e.id) && e.id > 0 && e.t != null && e.t + e.runtime * 60000 > now) + .sort((a, b) => a.t - b.t); + for (const e of coming) { + if (ids.length >= limit) break; + if (!ids.includes(e.id)) ids.push(e.id); + } + return ids; +} + +/** Episodes. Several episodes of one series at one time become one row. An + episode whose series is unknown is left out. + @param {unknown} episodes Sonarr /api/v3/calendar + @param {(e: any) => any} [seriesOf] the series record for an episode */ +function sonarrItems(episodes, seriesOf = e => e.series) { + const rows = []; + for (const e of list(episodes)) { + const found = seriesOf(e); + const s = found && typeof found === 'object' ? found : {}; + const title = str(s.title); + const t = ms(e.airDateUtc); + if (!title || t == null) continue; + /* A daily show numbers by air date, often with the year as its season and the date as its + name. Both repeat the date the row already shows. */ + const daily = str(s.seriesType) === 'daily' || whole(e.seasonNumber) >= 1900; + const season = daily ? null : whole(e.seasonNumber); + const episode = daily ? null : whole(e.episodeNumber); + const runtime = whole(e.runtime) || whole(s.runtime) || DEFAULT_RUNTIME_MIN; + const name = str(e.title); + rows.push( + makeItem({ + kind: 'episode', + title, + at: new Date(t).toISOString(), + until: t + runtime * 60000, + season, + episode, + lastEpisode: episode, + name: name && name.toUpperCase() !== 'TBA' && !daily && !namesItsDate(name, t) ? name : '', + premiere: episode === 1 && season !== 0, + finale: ['season', 'series'].includes(str(e.finaleType)) ? str(e.finaleType) : '', + path: slugPath('/series', str(s.titleSlug)), + key: `episode|${title.toLowerCase()}|${daily ? t : `${season}|${episode}`}`, + }), + ); + } + rows.sort( + (a, b) => + Date.parse(a.at) - Date.parse(b.at) || a.title.localeCompare(b.title) || (a.episode ?? 0) - (b.episode ?? 0), + ); + const merged = []; + for (const r of rows) { + const prev = merged[merged.length - 1]; + if ( + prev && + prev.title === r.title && + prev.at === r.at && + prev.season === r.season && + prev.lastEpisode != null && + r.episode === prev.lastEpisode + 1 + ) { + prev.lastEpisode = r.episode; + prev.name = ''; + prev.finale = r.finale || prev.finale; + prev.until = Math.max(prev.until, r.until); + continue; + } + merged.push(r); + } + return merged; +} + +/** Films. Each release date that falls in the window is its own row. + @param {unknown} movies Radarr /api/v3/calendar @param {number} from @param {number} to + @param {string[]} [types] release kinds to keep: cinema, digital, physical */ +function radarrItems(movies, from, to, types) { + const keep = new Set(Array.isArray(types) ? types : RELEASES.map(([, k]) => k)); + const out = []; + for (const m of list(movies)) { + const title = str(m.title); + if (!title) continue; + for (const [field, release] of RELEASES) { + if (!keep.has(release)) continue; + const day = dayOf(m[field]); + if (!day) continue; + const start = dayStartMs(day); + if (start + DAY_MS <= from || start > to) continue; + out.push( + makeItem({ + kind: 'film', + title, + day, + until: start + DAY_MS, + release, + year: whole(m.year), + path: slugPath('/movie', str(m.titleSlug)), + key: `film|${title.toLowerCase()}|${release}|${day}`, + }), + ); + } + } + return out; +} + +const ALBUM_TYPES = new Set(['album', 'ep', 'single']); +/** Albums. @param {unknown} albums Lidarr /api/v1/calendar with includeArtist */ +function lidarrItems(albums) { + const out = []; + for (const a of list(albums)) { + const title = str(a.title); + const day = dayOf(a.releaseDate); + if (!title || !day) continue; + const artist = a.artist && typeof a.artist === 'object' ? str(a.artist.artistName) : ''; + const type = str(a.albumType).toLowerCase(); + out.push( + makeItem({ + kind: 'album', + title, + day, + until: dayStartMs(day) + DAY_MS, + artist, + albumType: ALBUM_TYPES.has(type) ? type : '', + path: slugPath('/album', str(a.foreignAlbumId)), + key: `album|${title.toLowerCase()}|${artist.toLowerCase()}|${day}`, + }), + ); + } + return out; +} + +const sortKey = i => (i.at ? Date.parse(i.at) : dayStartMs(i.day)); + +/** Every source's rows, ended ones dropped, duplicates from a second instance + removed, soonest first. @param {object[][]} groups @param {number} now */ +function mergeItems(groups, now) { + const seen = new Set(); + const out = []; + for (const i of groups.flat()) { + if (!(i.until > now) || seen.has(i.key)) continue; + seen.add(i.key); + out.push(i); + } + out.sort((a, b) => sortKey(a) - sortKey(b) || a.title.localeCompare(b.title)); + return out.slice(0, MAX_ITEMS).map(({ key, until, ...rest }) => ({ ...rest, ends: new Date(until).toISOString() })); +} + +module.exports = { sonarrSeriesIds, sonarrItems, radarrItems, lidarrItems, mergeItems, MAX_ITEMS, DAY_MS }; diff --git a/ui/widgets/upnext/upnext-logic.js b/ui/widgets/upnext/upnext-logic.js new file mode 100644 index 00000000..e236526c --- /dev/null +++ b/ui/widgets/upnext/upnext-logic.js @@ -0,0 +1,38 @@ +// @ts-check + +/** @typedef {{ kind: string, title: string, at: string|null, day: string, ends: string }} Item */ + +/** A local calendar day as YYYY-MM-DD. @param {Date} d @returns {string} */ +export function localDay(d) { + const p = (/** @type {number} */ n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} + +/** Whole local days from `now` to `d`: 0 today, 1 tomorrow. + @param {Date} d @param {Date} now @returns {number} */ +export function dayOffset(d, now) { + const a = Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()); + const b = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + return Math.round((a - b) / 86400000); +} + +/** Items still to come in the reader's own time zone. A timed item goes once it + ends; a day item stays for its whole local day. + @param {unknown} items @param {Date} now @returns {Item[]} */ +export function visibleItems(items, now) { + if (!Array.isArray(items)) return []; + const today = localDay(now); + return items.filter(i => { + if (!i || typeof i !== 'object' || typeof i.title !== 'string') return false; + if (typeof i.at === 'string' && i.at) { + const ends = Date.parse(i.ends); + return Number.isFinite(ends) ? ends > now.getTime() : Date.parse(i.at) > now.getTime(); + } + return typeof i.day === 'string' && i.day >= today; + }); +} + +/** @param {Item} i @returns {string} */ +export function kindClass(i) { + return i.kind === 'film' || i.kind === 'album' ? i.kind : 'episode'; +} diff --git a/ui/widgets/upnext/widget.json b/ui/widgets/upnext/widget.json new file mode 100644 index 00000000..bae6da14 --- /dev/null +++ b/ui/widgets/upnext/widget.json @@ -0,0 +1,105 @@ +{ + "name": "upnext", + "label": "widget.label", + "description": "widget.description", + "glyph": "calendar", + "sizes": [ + "small", + "medium", + "large" + ], + "fields": [ + { + "key": "sources", + "type": "group", + "label": "sources.label", + "hint": "sources.hint", + "min": 1, + "max": 6, + "fields": [ + { + "key": "app", + "type": "select", + "variant": "pills", + "label": "app.label", + "default": "sonarr", + "options": [ + { + "value": "sonarr", + "label": "app.opt.sonarr" + }, + { + "value": "radarr", + "label": "app.opt.radarr" + }, + { + "value": "lidarr", + "label": "app.opt.lidarr" + } + ] + }, + { + "key": "url", + "type": "text", + "label": "url.label", + "placeholder": "url.placeholder" + }, + { + "key": "apiKey", + "type": "secret", + "label": "apiKey.label", + "hint": "apiKey.hint" + }, + { + "key": "releases", + "type": "multiselect", + "label": "releases.label", + "default": [ + "cinema", + "digital" + ], + "options": [ + { + "value": "cinema", + "label": "releases.opt.cinema" + }, + { + "value": "digital", + "label": "releases.opt.digital" + }, + { + "value": "physical", + "label": "releases.opt.physical" + } + ], + "showIf": { + "field": "app", + "equals": "radarr" + }, + "cosmetic": true + }, + { + "key": "skipTls", + "type": "toggle", + "label": "skipTls.label", + "default": false + }, + { + "key": "href", + "type": "text", + "label": "href.label", + "hint": "href.hint", + "optional": true, + "cosmetic": true + } + ] + }, + { + "key": "unmonitored", + "type": "toggle", + "label": "unmonitored.label", + "default": false, + "cosmetic": true + } + ] +} diff --git a/ui/widgets/weather/index.html b/ui/widgets/weather/index.html index 4ea02f5b..9ae21da2 100644 --- a/ui/widgets/weather/index.html +++ b/ui/widgets/weather/index.html @@ -73,8 +73,8 @@
    + diff --git a/ui/index.html b/ui/index.html index 3e644c05..6b8c6ee9 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 17bd9de6..33dfc6c8 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -33,13 +33,13 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=1f6cf982'; -import { createListbox } from '/js/listbox.js?v=b4aef017'; +} from '/js/admin-shared.js?v=8fec4e2f'; +import { createListbox } from '/js/listbox.js?v=a1c5118c'; import { MAX_LABELS } from '/js/badge-logic.js?v=85b1bc96'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=75b15192'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=8d54397d'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; export function buildFolderForm(body, item) { const children = item?.children || []; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index acf2f514..41c5f3d4 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=1f6cf982'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=8fec4e2f'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=a60b17ac'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index a03bbbba..b6bcb6d0 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,9 +1,9 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=1f6cf982'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=8fec4e2f'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=cfee1271'; -import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; -import { createListbox } from '/js/listbox.js?v=b4aef017'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; +import { createListbox } from '/js/listbox.js?v=a1c5118c'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 889a85b4..8b4186bf 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -18,13 +18,13 @@ import { listGroup } from '/js/admin-drag-logic.js?v=1d1d553e'; import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=14bd8c6c'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { paintIcon, toast } from '/js/admin-shared.js?v=1f6cf982'; +import { paintIcon, toast } from '/js/admin-shared.js?v=8fec4e2f'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=cfee1271'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=406af7a9'; -import { widgetGlyph } from '/js/widget-glyphs.js?v=68cc7695'; -import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; +import { sizeLabel } from '/js/admin-widget-form.js?v=044c037d'; +import { widgetGlyph } from '/js/widget-glyphs.js?v=479a53ee'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; /** @type {{ openModal: (idx: number|null) => void, openFolderPicker: (appId: string|null, targetFolderId?: string|null) => void, diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 1ccfb679..c2f5c106 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=1f6cf982'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=8fec4e2f'; import { socketProbeAdvice } from '/js/admin-error.js?v=a60b17ac'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -14,7 +14,7 @@ import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=cfee1271'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=32cb6aeb'; -import { renderColorControl } from '/js/admin-color-control.js?v=75b15192'; +import { renderColorControl } from '/js/admin-color-control.js?v=8d54397d'; import { BACKDROP } from '/js/background.js?v=72db97ac'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index da8ecf96..148da1d2 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -5,7 +5,7 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=a60b17ac'; import { formatList, formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { iconChain } from '/js/icons.js?v=5ac01d72'; -import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; export const API = ''; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index d97679f7..43e73945 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,12 +1,12 @@ import { state } from '/js/admin-state.js?v=064b9b85'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=1f6cf982'; -import { createListbox } from '/js/listbox.js?v=b4aef017'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=a99b1926'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=8fec4e2f'; +import { createListbox } from '/js/listbox.js?v=a1c5118c'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=98d652bf'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=b3befae4'; import { t } from '/js/i18n.js?v=ed7c5565'; import { focusFirst, q, qi, qa } from '/js/utils.js?v=cfee1271'; -import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/ui/js/admin.js b/ui/js/admin.js index daedd7ad..3b6131d8 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=a27eed58'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=5bdfa3ba'; +} from '/js/admin-app-form.js?v=2406e0a9'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9b2d4d17'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=143595bd'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=ee0ab6d5'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=b3befae4'; import { afterImport, @@ -31,7 +31,7 @@ import { wireUnsplashKey, wireProxmox, applyHealthChecks, -} from '/js/admin-settings.js?v=a04f44de'; +} from '/js/admin-settings.js?v=b91d8a7b'; import { apiGet, apiPost, @@ -45,17 +45,17 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=1f6cf982'; +} from '/js/admin-shared.js?v=8fec4e2f'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=406af7a9'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=044c037d'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=b4aef017'; +import { createListbox } from '/js/listbox.js?v=a1c5118c'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=88ae97cc'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=26655aca'; import { clearSkipTls, convert, diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 37fe7e67..eaced0f5 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -64,7 +64,7 @@ import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=88ae97cc'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=26655aca'; import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43'; import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=eeafa4b5'; diff --git a/ui/js/icon-set.js b/ui/js/icon-set.js index b06bcaa3..b725b3e2 100644 --- a/ui/js/icon-set.js +++ b/ui/js/icon-set.js @@ -229,27 +229,20 @@ export const ICONS = { dot(17, 14, T.NODE, 'accent'), ], }, - downloads: { - inkMask: false, - shapes: [ - detail(2, 3, 11, 4, T.R_SM, 'accent'), - detail(15, 3, 7, 4, T.R_SM), - rect(3, 9, 18, 12, T.R_MD), - bar(6, 13, 9), - bar(6, 17, 5), - ], - }, + downloads: { shapes: [line('M12 2v8M8 6l4 4 4-4', 'accent'), rect(3, 12, 18, 9, T.R_MD)] }, nowplaying: { shapes: [rect(3, 3, 18, 18, T.R_LG), shape('M10 8v8l6-4z', 'accent', { rj: true })] }, upnext: { - gap: 0, + inkMask: false, shapes: [ - rect(2, 4, 20, 16, T.R_LG), - bar(5, 7, 3), - bar(10, 7, 9, 'accent'), - bar(5, 11, 3), - bar(10, 11, 7), - bar(5, 15, 3), - bar(10, 15, 8), + detail(2, 3, 4, 4, T.R_SM), + detail(8, 3, 2, 4, 1, 'accent'), + detail(12, 4, 10, 2, 1), + detail(2, 10, 4, 4, T.R_SM), + detail(8, 10, 2, 4, 1), + detail(12, 11, 7, 2, 1), + detail(2, 17, 4, 4, T.R_SM), + detail(8, 17, 2, 4, 1), + detail(12, 18, 9, 2, 1), ], }, system: { shapes: [line('M3 16 7 12 11 15 16 8'), dot(19, 6, T.NODE, 'accent')] }, diff --git a/ui/js/listbox.js b/ui/js/listbox.js index d14498ca..c4bcf122 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -11,7 +11,7 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa } from '/js/utils.js?v=cfee1271'; -import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index a0b9f21f..aa075020 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=1f6cf982'; -import { createListbox } from '/js/listbox.js?v=b4aef017'; -import { renderColorControl } from '/js/admin-color-control.js?v=75b15192'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=8fec4e2f'; +import { createListbox } from '/js/listbox.js?v=a1c5118c'; +import { renderColorControl } from '/js/admin-color-control.js?v=8d54397d'; import { seedCarried, applyOptionSet, diff --git a/ui/js/widget-glyphs.js b/ui/js/widget-glyphs.js index f66a5c0c..98edf40d 100644 --- a/ui/js/widget-glyphs.js +++ b/ui/js/widget-glyphs.js @@ -1,5 +1,5 @@ // @ts-check -import { iconSvg } from '/js/icon-set.js?v=88ae97cc'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; /* Keys are the glyph names in widget.json files. Renaming one breaks that widget. */ /** @type {Record} */ diff --git a/ui/widgets/downloads/index.html b/ui/widgets/downloads/index.html index 24016ce8..f8d9c3a9 100644 --- a/ui/widgets/downloads/index.html +++ b/ui/widgets/downloads/index.html @@ -102,7 +102,7 @@ import { poll, getConfig, loadStrings, wt, wtCount, formatNumber, formatList, localiseDigits, linkTo, errorState, readingTime, } from '/js/widget-toolbox.js?v=c346a35b'; -import { ensureSprite } from '/js/icon-set.js?v=88ae97cc'; +import { ensureSprite } from '/js/icon-set.js?v=26655aca'; import { variations, finishedToday, hoursMinutes, leftover, lidOpen, startOfDay } from './belt-logic.js'; const SVG = 'http://www.w3.org/2000/svg'; diff --git a/ui/widgets/weather/index.html b/ui/widgets/weather/index.html index 9ae21da2..76dedeab 100644 --- a/ui/widgets/weather/index.html +++ b/ui/widgets/weather/index.html @@ -74,7 +74,7 @@ + diff --git a/ui/index.html b/ui/index.html index 6b8c6ee9..e62cef40 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 33dfc6c8..d6101b44 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -34,11 +34,11 @@ import { setTogDisabled, swapContent, } from '/js/admin-shared.js?v=8fec4e2f'; -import { createListbox } from '/js/listbox.js?v=a1c5118c'; +import { createListbox } from '/js/listbox.js?v=6f0a8c6a'; import { MAX_LABELS } from '/js/badge-logic.js?v=85b1bc96'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=8d54397d'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=a114899c'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=d9fdf901'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; export function buildFolderForm(body, item) { diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index b6bcb6d0..80c5ebf3 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -3,7 +3,7 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=cfee1271'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; -import { createListbox } from '/js/listbox.js?v=a1c5118c'; +import { createListbox } from '/js/listbox.js?v=6f0a8c6a'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 8b4186bf..308ff73e 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -22,7 +22,7 @@ import { paintIcon, toast } from '/js/admin-shared.js?v=8fec4e2f'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=cfee1271'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=044c037d'; +import { sizeLabel } from '/js/admin-widget-form.js?v=417c729e'; import { widgetGlyph } from '/js/widget-glyphs.js?v=479a53ee'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index c2f5c106..0415d18b 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -14,7 +14,7 @@ import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=cfee1271'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=32cb6aeb'; -import { renderColorControl } from '/js/admin-color-control.js?v=8d54397d'; +import { renderColorControl } from '/js/admin-color-control.js?v=a114899c'; import { BACKDROP } from '/js/background.js?v=72db97ac'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 43e73945..1246cad6 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,7 +1,7 @@ import { state } from '/js/admin-state.js?v=064b9b85'; import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=8fec4e2f'; -import { createListbox } from '/js/listbox.js?v=a1c5118c'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=98d652bf'; +import { createListbox } from '/js/listbox.js?v=6f0a8c6a'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=f6dd5127'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=b3befae4'; import { t } from '/js/i18n.js?v=ed7c5565'; diff --git a/ui/js/admin.js b/ui/js/admin.js index 3b6131d8..162b68cf 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=2406e0a9'; +} from '/js/admin-app-form.js?v=1111c22f'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9b2d4d17'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=ee0ab6d5'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=a2034e3f'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=b3befae4'; import { afterImport, @@ -31,7 +31,7 @@ import { wireUnsplashKey, wireProxmox, applyHealthChecks, -} from '/js/admin-settings.js?v=b91d8a7b'; +} from '/js/admin-settings.js?v=53dd7f7f'; import { apiGet, apiPost, @@ -47,11 +47,11 @@ import { withheldText, } from '/js/admin-shared.js?v=8fec4e2f'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=044c037d'; -import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=417c729e'; +import { initFluidHover } from '/js/fluid-hover.js?v=d9fdf901'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=a1c5118c'; +import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=d9edfad6'; +import { createListbox } from '/js/listbox.js?v=6f0a8c6a'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index eaced0f5..a21c837e 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -29,8 +29,8 @@ import { teardownWidgets, titleWhenTruncated, } from '/js/utils.js?v=cfee1271'; -import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=e6c4a09a'; +import { initFluidHover } from '/js/fluid-hover.js?v=d9fdf901'; +import { initSpotlight } from '/js/spotlight.js?v=62f5af44'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; diff --git a/ui/js/fluid-hover.js b/ui/js/fluid-hover.js index 61233dde..fb87fd3d 100644 --- a/ui/js/fluid-hover.js +++ b/ui/js/fluid-hover.js @@ -1,7 +1,7 @@ /* A single highlight per list, moved instead of redrawn. It follows the pointer, and falls back to the keyboard cursor when the pointer leaves. */ -import { ensurePill, pillOf, placePill, restPill } from '/js/moving-pill.js?v=4e961dec'; +import { ensurePill, pillOf, placePill, restPill } from '/js/moving-pill.js?v=4ef92618'; const FINE = '(hover: hover) and (pointer: fine)'; @@ -105,7 +105,8 @@ export function fluidHoverClear(container) { refresh(container); } -export function initFluidHover(root = document) { +/** @param {Document} [root] @param {{ container: string, item: string }[]} [lists] */ +export function initFluidHover(root = document, lists = LISTS) { if (!fluidHoverSupported()) return; root.addEventListener( 'pointerover', @@ -113,7 +114,7 @@ export function initFluidHover(root = document) { if (e.pointerType && e.pointerType !== 'mouse') return; const from = /** @type {Element} */ (e.target); if (!from || !from.closest) return; - for (const cfg of LISTS) { + for (const cfg of lists) { const item = from.closest(cfg.item); if (!item) continue; const container = item.closest(cfg.container); diff --git a/ui/js/glide-select.js b/ui/js/glide-select.js index 89b18ae7..1438a772 100644 --- a/ui/js/glide-select.js +++ b/ui/js/glide-select.js @@ -4,7 +4,7 @@ Not gated on a pointer: this is selection, not hover, so the phone tab bar gets it too. */ -import { ensurePill, measurable, placePill } from '/js/moving-pill.js?v=4e961dec'; +import { ensurePill, measurable, placePill } from '/js/moving-pill.js?v=4ef92618'; const NAVS = [ { container: '.sb-nav', item: '.nl', holdInk: true }, diff --git a/ui/js/listbox.js b/ui/js/listbox.js index c4bcf122..7f005b96 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -7,7 +7,7 @@ which exposes a strip of page canvas and misplaces the menu. */ import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=b3befae4'; -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=d9fdf901'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa } from '/js/utils.js?v=cfee1271'; diff --git a/ui/js/moving-pill.js b/ui/js/moving-pill.js index f53b85c4..e08c36f6 100644 --- a/ui/js/moving-pill.js +++ b/ui/js/moving-pill.js @@ -38,20 +38,24 @@ export function placePill(container, pill, item) { const cr = container.getBoundingClientRect(); const ir = item.getBoundingClientRect(); const cs = getComputedStyle(item); + /* Rects are in screen pixels and styles in the page's own. A zoomed widget + page differs by its zoom, and the pill comes out that much too large. */ + const w = /** @type {HTMLElement} */ (container).offsetWidth; + const z = w ? cr.width / w : 1; pill.style.borderRadius = [ cs.borderTopLeftRadius, cs.borderTopRightRadius, cs.borderBottomRightRadius, cs.borderBottomLeftRadius, ].join(' '); - pill.style.width = ir.width + 'px'; - pill.style.height = ir.height + 'px'; + pill.style.width = ir.width / z + 'px'; + pill.style.height = ir.height / z + 'px'; /* The pill is anchored at the inline start, so right-to-left measures from the right edge and travels the other way. None of these lists scroll sideways, so the rect difference is the whole inline offset. */ const rtl = cs.direction === 'rtl'; - const inline = (rtl ? cr.right - ir.right : ir.left - cr.left) - container.clientLeft; - const block = ir.top - cr.top - container.clientTop + container.scrollTop; + const inline = (rtl ? cr.right - ir.right : ir.left - cr.left) / z - container.clientLeft; + const block = (ir.top - cr.top) / z - container.clientTop + container.scrollTop; pill.style.transform = 'translate3d(' + (rtl ? -inline : inline) + 'px,' + block + 'px,0)'; if (!pill.classList.contains('on')) { /* `.on` carries the transform transition, so it has to land in a later diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index 2b60e6b2..d3a67372 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,4 +1,4 @@ -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=d9fdf901'; import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=cfee1271'; import { t } from '/js/i18n.js?v=ed7c5565'; diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index aa075020..43a81765 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -4,8 +4,8 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=8fec4e2f'; -import { createListbox } from '/js/listbox.js?v=a1c5118c'; -import { renderColorControl } from '/js/admin-color-control.js?v=8d54397d'; +import { createListbox } from '/js/listbox.js?v=6f0a8c6a'; +import { renderColorControl } from '/js/admin-color-control.js?v=a114899c'; import { seedCarried, applyOptionSet, diff --git a/ui/test/fluid-hover.test.mjs b/ui/test/fluid-hover.test.mjs index a43bb961..8dd935d6 100644 --- a/ui/test/fluid-hover.test.mjs +++ b/ui/test/fluid-hover.test.mjs @@ -323,3 +323,46 @@ test('a detached container is not held', () => { mod.fluidHoverClear(list); assert.equal(mod._liveHolds(list), false, 'the detached list is still held'); }); + +/* A bundled widget page is zoomed to its card. Rects come back zoomed, styles + are applied unzoomed: an unscaled pill is the zoom factor too large. */ +test('a zoomed page places and sizes the pill in its own units', () => { + install(); + const list = fake('ol', { rect: { left: 0, top: 0, width: 400, height: 300 } }); + list.offsetWidth = 200; + const row = fake('div', { rect: { left: 0, top: 100, width: 400, height: 80 } }); + row.parent = list; + row._sel = ['.row.clickable']; + list._sel = ['#rows']; + let over; + mod.initFluidHover({ addEventListener: (t, fn) => t === 'pointerover' && (over = fn) }, [ + { container: '#rows', item: '.row.clickable' }, + ]); + over({ pointerType: 'mouse', target: row }); + const pill = list.children[0]; + assert.equal(pill.style.width, '200px'); + assert.equal(pill.style.height, '40px'); + assert.equal(pill.style.transform, 'translate3d(0px,50px,0)'); +}); + +test('a list passed to initFluidHover is followed, and the built-in lists are not', () => { + install(); + const own = fake('ol', { rect: { left: 0, top: 0, width: 200, height: 120 } }); + own._sel = ['#rows']; + const row = fake('div', { rect: { left: 0, top: 40, width: 200, height: 40 } }); + row._sel = ['.row.clickable']; + row.parent = own; + const other = fake('ul', { rect: { left: 0, top: 0, width: 200, height: 120 } }); + other._sel = ['.row-dd-list']; + const li = fake('li', { rect: { left: 0, top: 0, width: 200, height: 40 } }); + li._sel = ['li[role="option"]']; + li.parent = other; + let over; + mod.initFluidHover({ addEventListener: (t, fn) => t === 'pointerover' && (over = fn) }, [ + { container: '#rows', item: '.row.clickable' }, + ]); + over({ pointerType: 'mouse', target: row }); + assert.equal(own.children.length, 1); + over({ pointerType: 'mouse', target: li }); + assert.equal(other.children.length, 0); +}); diff --git a/ui/widgets/upnext/index.html b/ui/widgets/upnext/index.html index 40b61765..b8f2586e 100644 --- a/ui/widgets/upnext/index.html +++ b/ui/widgets/upnext/index.html @@ -12,11 +12,16 @@ .card{position:relative;width:100%;height:100%;padding:16px;border-radius:inherit;outline:none} .card.clickable{cursor:pointer} .card.clickable:focus-visible{box-shadow:inset 0 0 0 2px #00D2E0} - .rows{list-style:none;height:100%;display:grid;grid-template-rows:repeat(var(--rows,3),1fr);min-width:0} + .rows{position:relative;list-style:none;height:100%;display:grid;grid-template-rows:repeat(var(--rows,3),1fr);min-width:0} .card.capped .rows>li:last-child{visibility:hidden} .rows>li{display:flex;min-width:0} - .row{position:relative;flex:1;display:flex;align-items:center;gap:9px;min-width:0;border-radius:10px;outline:none} + .row{position:relative;flex:1;display:flex;align-items:center;gap:9px;min-width:0;margin-inline:-8px;padding-inline:8px;border-radius:12px;outline:none;transition:background .12s} .row.clickable{cursor:pointer} + .row.clickable:active{background:rgba(255,255,255,.08)} + .mp{position:absolute;top:0;inset-inline-start:0;pointer-events:none;opacity:0;transition:opacity .14s ease} + .mp.on{opacity:1;transition:opacity .14s ease,transform .19s cubic-bezier(.22,1,.36,1),width .19s cubic-bezier(.22,1,.36,1),height .19s cubic-bezier(.22,1,.36,1)} + .fh-hl{background:rgba(255,255,255,.08)} + @media (prefers-reduced-motion: reduce){.mp,.mp.on{transition:none}} .row.clickable:focus-visible{outline:2px solid #00D2E0;outline-offset:-1px} .gt{flex:none;width:var(--gt,44px);text-align:end;line-height:1.2;font-variant-numeric:tabular-nums;white-space:nowrap} .gt b{display:block;font-size:13px;font-weight:600} @@ -43,15 +48,20 @@ html[data-theme="light"] .film .bar{background:#B25000} html[data-theme="light"] .album .bar{background:#248A3D} html[data-theme="light"] .row.clickable:focus-visible{outline-color:#0088FF} + html[data-theme="light"] .row.clickable:active{background:rgba(60,60,67,.08)} + html[data-theme="light"] .fh-hl{background:rgba(60,60,67,.07)} html[data-theme="light"] .card.clickable:focus-visible{box-shadow:inset 0 0 0 2px #0088FF} @media (prefers-contrast: more) { .gt small,.sub{color:#EBEBF5} html[data-theme="light"] .gt small,html[data-theme="light"] .sub{color:#3C3C43} + .fh-hl{background:rgba(255,255,255,.16)} + html[data-theme="light"] .fh-hl{background:rgba(60,60,67,.14)} } @media (forced-colors: active) { .bar{forced-color-adjust:none;background:CanvasText!important} .row.today .gt b{color:Highlight} .row.clickable:focus-visible{outline-color:Highlight} + .fh-hl{outline:1px solid Highlight;outline-offset:-1px} .card.clickable:focus-visible{box-shadow:none;outline:2px solid Highlight;outline-offset:-3px} } @@ -63,6 +73,7 @@ + diff --git a/ui/index.html b/ui/index.html index e62cef40..e4d57689 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index d6101b44..5a3434ff 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -34,11 +34,11 @@ import { setTogDisabled, swapContent, } from '/js/admin-shared.js?v=8fec4e2f'; -import { createListbox } from '/js/listbox.js?v=6f0a8c6a'; +import { createListbox } from '/js/listbox.js?v=3e47b403'; import { MAX_LABELS } from '/js/badge-logic.js?v=85b1bc96'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=a114899c'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=d444b337'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=d9fdf901'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=8b4aadd4'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; export function buildFolderForm(body, item) { diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 80c5ebf3..0c6b6920 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -3,7 +3,7 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=cfee1271'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; -import { createListbox } from '/js/listbox.js?v=6f0a8c6a'; +import { createListbox } from '/js/listbox.js?v=3e47b403'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 308ff73e..fb6e1a42 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -22,7 +22,7 @@ import { paintIcon, toast } from '/js/admin-shared.js?v=8fec4e2f'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=cfee1271'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=417c729e'; +import { sizeLabel } from '/js/admin-widget-form.js?v=b0b45771'; import { widgetGlyph } from '/js/widget-glyphs.js?v=479a53ee'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 0415d18b..c08f7fdd 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -14,7 +14,7 @@ import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=cfee1271'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=32cb6aeb'; -import { renderColorControl } from '/js/admin-color-control.js?v=a114899c'; +import { renderColorControl } from '/js/admin-color-control.js?v=d444b337'; import { BACKDROP } from '/js/background.js?v=72db97ac'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 1246cad6..c6b0cd24 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,7 +1,7 @@ import { state } from '/js/admin-state.js?v=064b9b85'; import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=8fec4e2f'; -import { createListbox } from '/js/listbox.js?v=6f0a8c6a'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=f6dd5127'; +import { createListbox } from '/js/listbox.js?v=3e47b403'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=7e48b5b8'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=b3befae4'; import { t } from '/js/i18n.js?v=ed7c5565'; diff --git a/ui/js/admin.js b/ui/js/admin.js index 162b68cf..4288f29d 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=1111c22f'; +} from '/js/admin-app-form.js?v=6dfcf64d'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9b2d4d17'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=a2034e3f'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=9a75dcf8'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=b3befae4'; import { afterImport, @@ -31,7 +31,7 @@ import { wireUnsplashKey, wireProxmox, applyHealthChecks, -} from '/js/admin-settings.js?v=53dd7f7f'; +} from '/js/admin-settings.js?v=60c010a8'; import { apiGet, apiPost, @@ -47,11 +47,11 @@ import { withheldText, } from '/js/admin-shared.js?v=8fec4e2f'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=417c729e'; -import { initFluidHover } from '/js/fluid-hover.js?v=d9fdf901'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=b0b45771'; +import { initFluidHover } from '/js/fluid-hover.js?v=8b4aadd4'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=d9edfad6'; -import { createListbox } from '/js/listbox.js?v=6f0a8c6a'; +import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=75a71ab4'; +import { createListbox } from '/js/listbox.js?v=3e47b403'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index a21c837e..0be7ac15 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -29,8 +29,8 @@ import { teardownWidgets, titleWhenTruncated, } from '/js/utils.js?v=cfee1271'; -import { initFluidHover } from '/js/fluid-hover.js?v=d9fdf901'; -import { initSpotlight } from '/js/spotlight.js?v=62f5af44'; +import { initFluidHover } from '/js/fluid-hover.js?v=8b4aadd4'; +import { initSpotlight } from '/js/spotlight.js?v=06e7ef30'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; diff --git a/ui/js/fluid-hover.js b/ui/js/fluid-hover.js index fb87fd3d..de80972b 100644 --- a/ui/js/fluid-hover.js +++ b/ui/js/fluid-hover.js @@ -1,7 +1,7 @@ /* A single highlight per list, moved instead of redrawn. It follows the pointer, and falls back to the keyboard cursor when the pointer leaves. */ -import { ensurePill, pillOf, placePill, restPill } from '/js/moving-pill.js?v=4ef92618'; +import { ensurePill, pillOf, placePill, restPill } from '/js/moving-pill.js?v=afe204a3'; const FINE = '(hover: hover) and (pointer: fine)'; @@ -35,7 +35,7 @@ function selectable(item) { function stateFor(container) { let s = states.get(container); if (!s) { - s = { hover: null, kb: null, bound: false }; + s = { hover: null, kb: null, bound: false, zoom: null }; states.set(container, s); } return s; @@ -50,7 +50,7 @@ function refresh(container) { if (target && target.isConnected && selectable(target)) { const pill = ensurePill(container, 'fh-hl'); live.add(container); - placePill(container, pill, target); + placePill(container, pill, target, s.zoom ? s.zoom() : 1); } else { restPill(pillOf(container)); live.delete(container); @@ -105,7 +105,8 @@ export function fluidHoverClear(container) { refresh(container); } -/** @param {Document} [root] @param {{ container: string, item: string }[]} [lists] */ +/** @param {Document} [root] + @param {{ container: string, item: string, zoom?: () => number }[]} [lists] */ export function initFluidHover(root = document, lists = LISTS) { if (!fluidHoverSupported()) return; root.addEventListener( @@ -119,7 +120,9 @@ export function initFluidHover(root = document, lists = LISTS) { if (!item) continue; const container = item.closest(cfg.container); if (!container || !selectable(item)) continue; - stateFor(container).hover = item; + const s = stateFor(container); + s.hover = item; + s.zoom = cfg.zoom || null; bind(container); refresh(container); return; diff --git a/ui/js/glide-select.js b/ui/js/glide-select.js index 1438a772..754091b9 100644 --- a/ui/js/glide-select.js +++ b/ui/js/glide-select.js @@ -4,7 +4,7 @@ Not gated on a pointer: this is selection, not hover, so the phone tab bar gets it too. */ -import { ensurePill, measurable, placePill } from '/js/moving-pill.js?v=4ef92618'; +import { ensurePill, measurable, placePill } from '/js/moving-pill.js?v=afe204a3'; const NAVS = [ { container: '.sb-nav', item: '.nl', holdInk: true }, diff --git a/ui/js/listbox.js b/ui/js/listbox.js index 7f005b96..a57dcfd7 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -7,7 +7,7 @@ which exposes a strip of page canvas and misplaces the menu. */ import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=b3befae4'; -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=d9fdf901'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=8b4aadd4'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa } from '/js/utils.js?v=cfee1271'; diff --git a/ui/js/moving-pill.js b/ui/js/moving-pill.js index e08c36f6..01a5b4af 100644 --- a/ui/js/moving-pill.js +++ b/ui/js/moving-pill.js @@ -32,16 +32,15 @@ export function measurable(el) { return r.width > 0 && r.height > 0; } -/** @param {Element} container @param {HTMLElement} pill @param {Element} item */ -export function placePill(container, pill, item) { +/** Pass the page's CSS zoom when it has one. Rects include it and styles do + not, so the pill otherwise comes out that much too large and off its row. + @param {Element} container @param {HTMLElement} pill @param {Element} item + @param {number} [z] */ +export function placePill(container, pill, item, z = 1) { if (!measurable(container) || !measurable(item)) return false; const cr = container.getBoundingClientRect(); const ir = item.getBoundingClientRect(); const cs = getComputedStyle(item); - /* Rects are in screen pixels and styles in the page's own. A zoomed widget - page differs by its zoom, and the pill comes out that much too large. */ - const w = /** @type {HTMLElement} */ (container).offsetWidth; - const z = w ? cr.width / w : 1; pill.style.borderRadius = [ cs.borderTopLeftRadius, cs.borderTopRightRadius, diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index d3a67372..9074a7df 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,4 +1,4 @@ -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=d9fdf901'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=8b4aadd4'; import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=cfee1271'; import { t } from '/js/i18n.js?v=ed7c5565'; diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index 43a81765..fa296b56 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -4,8 +4,8 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=8fec4e2f'; -import { createListbox } from '/js/listbox.js?v=6f0a8c6a'; -import { renderColorControl } from '/js/admin-color-control.js?v=a114899c'; +import { createListbox } from '/js/listbox.js?v=3e47b403'; +import { renderColorControl } from '/js/admin-color-control.js?v=d444b337'; import { seedCarried, applyOptionSet, diff --git a/ui/test/fluid-hover.test.mjs b/ui/test/fluid-hover.test.mjs index 8dd935d6..d6caf814 100644 --- a/ui/test/fluid-hover.test.mjs +++ b/ui/test/fluid-hover.test.mjs @@ -329,14 +329,13 @@ test('a detached container is not held', () => { test('a zoomed page places and sizes the pill in its own units', () => { install(); const list = fake('ol', { rect: { left: 0, top: 0, width: 400, height: 300 } }); - list.offsetWidth = 200; const row = fake('div', { rect: { left: 0, top: 100, width: 400, height: 80 } }); row.parent = list; row._sel = ['.row.clickable']; list._sel = ['#rows']; let over; mod.initFluidHover({ addEventListener: (t, fn) => t === 'pointerover' && (over = fn) }, [ - { container: '#rows', item: '.row.clickable' }, + { container: '#rows', item: '.row.clickable', zoom: () => 2 }, ]); over({ pointerType: 'mouse', target: row }); const pill = list.children[0]; @@ -345,6 +344,24 @@ test('a zoomed page places and sizes the pill in its own units', () => { assert.equal(pill.style.transform, 'translate3d(0px,50px,0)'); }); +test('an unzoomed list with a fractional width places the pill on its row', () => { + install(); + const list = fake('ol', { rect: { left: 0, top: 0, width: 300.5, height: 6000 } }); + list.offsetWidth = 301; + const row = fake('div', { rect: { left: 0, top: 5000, width: 300.5, height: 52 } }); + row.parent = list; + row._sel = ['.row.clickable']; + list._sel = ['#rows']; + let over; + mod.initFluidHover({ addEventListener: (t, fn) => t === 'pointerover' && (over = fn) }, [ + { container: '#rows', item: '.row.clickable' }, + ]); + over({ pointerType: 'mouse', target: row }); + const pill = list.children[0]; + assert.equal(pill.style.width, '300.5px'); + assert.equal(pill.style.transform, 'translate3d(0px,5000px,0)'); +}); + test('a list passed to initFluidHover is followed, and the built-in lists are not', () => { install(); const own = fake('ol', { rect: { left: 0, top: 0, width: 200, height: 120 } }); diff --git a/ui/test/upnext-logic.test.mjs b/ui/test/upnext-logic.test.mjs index ab968b4a..3c9ee08f 100644 --- a/ui/test/upnext-logic.test.mjs +++ b/ui/test/upnext-logic.test.mjs @@ -36,7 +36,7 @@ test('a timed item stays until it ends; a day item for its whole local day', () ]; assert.deepEqual( visibleItems(items, now).map(i => i.title), - ['Airing', 'Today', 'Later'], + ['Today', 'Airing', 'Later'], ); assert.deepEqual(visibleItems('nope', now), []); }); @@ -47,3 +47,22 @@ test('kindClass maps every kind to a bar colour class', () => { assert.equal(kindClass({ kind: 'episode' }), 'episode'); assert.equal(kindClass({ kind: 'other' }), 'episode'); }); + +test('rows sort in local time: tonight’s episode comes before tomorrow’s film', () => { + const now = at(2026, 10, 25, 19); + const fromServer = [ + { kind: 'film', title: 'Tomorrow', at: null, day: localDay(at(2026, 10, 26)), ends: '' }, + { + kind: 'episode', + title: 'Tonight', + at: at(2026, 10, 25, 21).toISOString(), + day: '', + ends: at(2026, 10, 25, 22).toISOString(), + }, + { kind: 'album', title: 'Today', at: null, day: localDay(now), ends: '' }, + ]; + assert.deepEqual( + visibleItems(fromServer, now).map(i => i.title), + ['Today', 'Tonight', 'Tomorrow'], + ); +}); diff --git a/ui/widgets/upnext/data.js b/ui/widgets/upnext/data.js index 2642d7a9..f3a99fe5 100644 --- a/ui/widgets/upnext/data.js +++ b/ui/widgets/upnext/data.js @@ -9,8 +9,10 @@ const ENOUGH = 8; const MAX_SOURCES = 6; const DEFAULT_RELEASES = Object.freeze(['cinema', 'digital']); const SERIES_TTL_MS = 6 * 3600 * 1000; -/* Keep the bound. A preview can name any address. */ +/* Keep both bounds. A preview can name any address, and its reply sets the size. */ const SERIES_KEPT = 500; +const TEXT_KEPT = 300; +const SERIES_TYPES = new Set(['standard', 'daily', 'anime']); const NAMES = Object.freeze({ sonarr: 'Sonarr', radarr: 'Radarr', lidarr: 'Lidarr' }); @@ -20,6 +22,7 @@ const TIMEOUT_CODES = new Set(['ETIMEDOUT', 'ESOCKETTIMEDOUT', 'UND_ERR_HEADERS_ function kindOf(e, ctx) { if (e && typeof e.kind === 'string') return e.kind; if (e && e.name === 'SsrfBlockedError') return ctx.KIND.BLOCKED; + if (e && e.message === 'Timed out') return ctx.KIND.TIMEOUT; const code = e && e.code; if (TIMEOUT_CODES.has(code)) return ctx.KIND.TIMEOUT; if (typeof code === 'string' && code) return ctx.KIND.NETWORK; @@ -62,6 +65,8 @@ async function calendar(ctx, row, path, from, to, extra = '') { return r.data; } +const kept = v => (typeof v === 'string' ? v.slice(0, TEXT_KEPT) : ''); + const seriesCache = new Map(); function remember(key, value) { seriesCache.delete(key); @@ -85,9 +90,14 @@ async function sonarr(ctx, row, from, to, now) { if (r.status >= 400) ctx.fail(`Sonarr HTTP ${r.status}`); const s = r.data && typeof r.data === 'object' ? r.data : null; if (!s) return; - const kept = { title: s.title, titleSlug: s.titleSlug, runtime: s.runtime, seriesType: s.seriesType }; - remember(key, kept); - series.set(id, kept); + const one = { + title: kept(s.title), + titleSlug: kept(s.titleSlug), + runtime: items.minutes(s.runtime), + seriesType: SERIES_TYPES.has(s.seriesType) ? s.seriesType : '', + }; + remember(key, one); + series.set(id, one); }), ); return items.sonarrItems(episodes, e => series.get(e.seriesId)); @@ -138,13 +148,13 @@ module.exports = async function (ctx) { now, ).length < ENOUGH ) { - days = LONG_DAYS; const longer = await readAll( ctx, ok.map(r => r.row), now, - days, + LONG_DAYS, ); + if (longer.every(r => !r.error)) days = LONG_DAYS; results = longer.map((r, i) => (r.error ? ok[i] : r)).concat(results.filter(r => r.error)); } const first = rows.find(r => ctx.serviceLink(r.href, r.url, '/calendar')); diff --git a/ui/widgets/upnext/index.html b/ui/widgets/upnext/index.html index b8f2586e..9fa4be9b 100644 --- a/ui/widgets/upnext/index.html +++ b/ui/widgets/upnext/index.html @@ -72,8 +72,8 @@