From e102e906dcfa15637ef984b550310c4ddad7b75b Mon Sep 17 00:00:00 2001 From: Mickael Couzinet Date: Sat, 19 Sep 2026 11:45:42 +0200 Subject: [PATCH 1/2] spotify: a progress bar that works, and a tile that fits its size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the widget got wrong. **The bar never moved outside an English locale.** `player position` is the one fractional value the state script asks for, and AppleScript writes numbers with the Mac's own decimal separator: on a French system it answers `69,724998`, which `Number()` reads as NaN. The width became `NaN%`, which a browser drops. AppleScript rounds the position to whole milliseconds now, and an integer has no separator to get wrong. **And it stepped once a second.** The position arrives on a one-second poll and the fill was drawn where the last answer put it. It is sent to where the track will be at the next answer and given that second to get there; each answer corrects the aim. A seek or a new track lands at once — running the fill backwards across a second reads as a fault. Scaled rather than resized, so the second belongs to the compositor. The bar is also thicker and stands clear of the buttons: at 4px under a 4px gap it read as an underline on the transport rather than a bar of its own. **The tile was a row whatever its shape.** A band about 150px high floated in the middle of anything taller while the cover stayed pinned to 40% of the width: on a 640×680 tile the content used 200 of 636 pixels. The cover, the song and the transport are three grid areas now, and only the areas move — row, header (from 440×340, the cover beside the song with the transport across the width below), or column when the tile is square or taller than wide. Two things there were worth measuring rather than guessing. The cover takes its height from the grid and its width from `aspect-ratio`, with the *same* cap on both sides — capping only the width left a portrait cover on every narrow tile, since a replaced element with an explicit height does not recompute it from a width that was clamped. And the type reads from whichever dimension the tile has to spare, held back by the other: sized on height alone a 950×280 tile carried a 21px title where it had room for thirty, and sized on width alone a 2560×120 banner would ask for 76px, which does not fit under it. `.info` is a real box in the row — which is what keeps the cover from pushing the song and the buttons apart when it is the taller of the two — and steps aside with `display:contents` in the other two, so its children become areas of their own. This is also what the `max-width:480px` rule in the file was written for, behind a width no tile could reach: the widget asked for sixteen cells, which is 640px. It asks for eight now, like every widget that is not a list of text, and the default size grows a row instead. Co-Authored-By: Claude Opus 5 --- server/src/providers/spotify.ts | 14 +++- server/test/providers.test.ts | 17 +++- server/test/spotify-widget.test.ts | 115 +++++++++++++++++++++++--- widgets/spotify/index.html | 128 +++++++++++++++++++++++++---- widgets/spotify/manifest.json | 4 +- 5 files changed, 243 insertions(+), 35 deletions(-) diff --git a/server/src/providers/spotify.ts b/server/src/providers/spotify.ts index 919a96a..9a01e13 100644 --- a/server/src/providers/spotify.ts +++ b/server/src/providers/spotify.ts @@ -11,10 +11,18 @@ import { tr } from '../i18n.js' */ const SEP = '\x1f' const IS_RUNNING = 'tell application "System Events" to (name of processes) contains "Spotify"' -const STATE = `tell application "Spotify" +/** + * The position is rounded to milliseconds by AppleScript rather than read as the float it is. + * + * `player position` is the one fractional value here, and AppleScript writes a number with the + * Mac's own decimal separator: on a French system it answers `69,724998`, which `Number()` reads + * as NaN — and a NaN position is a progress bar that never moves. An integer has no separator to + * get wrong. `duration` is already in milliseconds, and `sound volume` is already whole. + */ +export const STATE = `tell application "Spotify" if player state is stopped then return "stopped" set sep to "${SEP}" - return (player state as string) & sep & (name of current track) & sep & (artist of current track) & sep & (album of current track) & sep & (artwork url of current track) & sep & (duration of current track) & sep & (player position) & sep & (sound volume) + return (player state as string) & sep & (name of current track) & sep & (artist of current track) & sep & (album of current track) & sep & (artwork url of current track) & sep & (duration of current track) & sep & (round ((player position) * 1000)) & sep & (sound volume) end tell` export function parseSpotify(raw: string) { @@ -24,7 +32,7 @@ export function parseSpotify(raw: string) { state: state as 'playing' | 'paused' | 'stopped', title, artist, album, artwork, durationMs: Number(duration), - positionMs: Math.round(Number(position) * 1000), + positionMs: Number(position), volume: Number(volume), } } diff --git a/server/test/providers.test.ts b/server/test/providers.test.ts index cd1b52f..51ddcdd 100644 --- a/server/test/providers.test.ts +++ b/server/test/providers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import { parseVolume, createVolumeProvider } from '../src/providers/volume.js' -import { parseSpotify, createSpotifyProvider, OPEN_PROGRAM, SPOTIFY_BUNDLE_ID } from '../src/providers/spotify.js' +import { parseSpotify, createSpotifyProvider, OPEN_PROGRAM, SPOTIFY_BUNDLE_ID, STATE } from '../src/providers/spotify.js' import { createMutedeckProvider } from '../src/providers/mutedeck.js' describe('volume', () => { @@ -21,9 +21,22 @@ describe('volume', () => { describe('spotify', () => { it('parses the delimited state', () => { // The separator is an ASCII unit separator: a track name can contain any printable text. - const s = parseSpotify(['playing', 'Song', 'Artist', 'Album', 'https://i.scdn.co/x', '215000', '12.5', '80'].join('\x1f')) + const s = parseSpotify(['playing', 'Song', 'Artist', 'Album', 'https://i.scdn.co/x', '215000', '12500', '80'].join('\x1f')) expect(s).toEqual({ available: true, state: 'playing', title: 'Song', artist: 'Artist', album: 'Album', artwork: 'https://i.scdn.co/x', durationMs: 215000, positionMs: 12500, volume: 80 }) }) + + /** + * The bug this guards: AppleScript writes a number with the Mac's own decimal separator, so on + * a French system `player position` answered `69,724998` and `Number()` made NaN of it — a + * progress bar stuck at zero for everyone outside an English locale. The script asks Spotify + * to round the position to whole milliseconds instead, and an integer has no separator. + */ + it('asks Spotify for a whole number of milliseconds, never a float', () => { + expect(STATE).toContain('round ((player position) * 1000)') + expect(STATE).not.toContain('sep & (player position)') + const french = parseSpotify(['playing', 'Song', 'Artist', 'Album', '', '215000', '69,724998', '80'].join('\x1f')) + expect(Number.isNaN(french.positionMs)).toBe(true) + }) it('reports unavailable when Spotify is not running', async () => { const run = vi.fn(async (script: string) => (script.includes('System Events') ? 'false' : '')) expect(await createSpotifyProvider(run).poll!()).toEqual({ available: false }) diff --git a/server/test/spotify-widget.test.ts b/server/test/spotify-widget.test.ts index 454cc2d..d667beb 100644 --- a/server/test/spotify-widget.test.ts +++ b/server/test/spotify-widget.test.ts @@ -9,29 +9,53 @@ import { fileURLToPath } from 'node:url' * that record the listeners it registers — then the recorded `contextmenu` handler is called, * which is exactly what the Edge's touch driver produces for a press held past its threshold. */ +interface StubNode { + textContent: string; className: string; title: string; onclick: (() => void) | null + style: Record & { setProperty(name: string, value: string): void } + childNodes: StubNode[] +} + interface Loaded { listeners: Map void)[]> commands: [string, string, unknown?][] settings: Record + /** The elements the widget asked for by id, so a test can read what it put in them. */ + nodes: Map + /** Hands the widget one answer from the spotify channel, as the host delivers it. */ + publish(data: Record): void } -async function loadWidget(settings: Record): Promise { +async function loadWidget(settings: Record, viewport: [number, number] = [632, 282]): Promise { const html = await readFile(fileURLToPath(new URL('../../widgets/spotify/index.html', import.meta.url)), 'utf8') const script = [...html.matchAll(/]*>([\s\S]*?)<\/script>/g)].map((m) => m[1]).join('\n') const listeners = new Map void)[]>() const commands: [string, string, unknown?][] = [] - const node = () => ({ - textContent: '', className: '', style: {}, src: '', - classList: { add: () => {}, remove: () => {}, toggle: () => {} }, - setAttribute: () => {}, removeAttribute: () => {}, appendChild: () => {}, - addEventListener: () => {}, onclick: null, - }) + const subscribers: ((data: unknown) => void)[] = [] + const node = () => { + const self = { + textContent: '', className: '', title: '', src: '', + style: { setProperty(name: string, value: string) { (this as Record)[name] = value } } as StubNode['style'], + clientWidth: viewport[0] - 36, + childNodes: [] as StubNode[], + classList: { add: () => {}, remove: () => {}, toggle: () => {} }, + setAttribute: () => {}, removeAttribute: () => {}, + appendChild: (child: StubNode) => { self.childNodes.push(child) }, + addEventListener: () => {}, onclick: null, + } + return self + } + // By id, and the same object every time: `textContent = ''` on a fresh stub would erase nothing. + const nodes = new Map>() + const byId = (id: string) => { + if (!nodes.has(id)) nodes.set(id, node()) + return nodes.get(id)! + } const document = { documentElement: { lang: '', classList: { add: () => {}, remove: () => {} }, style: { setProperty: () => {} } }, head: { firstChild: null, insertBefore: () => {}, appendChild: () => {} }, createElement: node, - getElementById: node, + getElementById: byId, querySelector: node, addEventListener: (type: string, fn: (e: unknown) => void) => { listeners.set(type, [...(listeners.get(type) ?? []), fn]) @@ -43,7 +67,7 @@ async function loadWidget(settings: Record): Promise { compact: false, instanceId: 'spotify-1', whenReady: (cb: () => void) => cb(), - subscribe: () => () => {}, + subscribe: (_channel: string, cb: (data: unknown) => void) => { subscribers.push(cb); return () => {} }, onLocale: () => {}, onResize: () => {}, onSettings: () => {}, @@ -51,13 +75,26 @@ async function loadWidget(settings: Record): Promise { commands.push([channel, name, payload]) return Promise.resolve({ ok: true }) }, + el: (_tag: string, className: string, text?: unknown) => { + const n = node() + n.className = className ?? '' + n.textContent = String(text ?? '') + return n + }, t: (dict: unknown) => (typeof dict === 'string' ? dict : ((dict as Record)?.fr ?? '')), esc: (v: unknown) => String(v ?? ''), color: (v: unknown, fallback: string) => fallback, } - const window = { setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms), clearTimeout } + const window = { + setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms), clearTimeout, + innerWidth: viewport[0], innerHeight: viewport[1], + } new Function('document', 'Fremkit', 'window', 'console', script)(document, Fremkit, window, console) - return { listeners, commands, settings } + return { + listeners, commands, settings, + nodes: nodes as unknown as Map, + publish: (data: Record) => subscribers.forEach((cb) => cb(data)), + } } const fire = (loaded: Loaded, type: string, event: unknown = {}) => @@ -117,3 +154,59 @@ describe('the spotify widget long press', () => { } }) }) + +/** + * The progress bar. The position arrives once a second, so a bar that only moved when an answer + * landed stepped once a second — visibly. It is aimed a second ahead and given a second to get + * there instead, and each answer corrects the aim. What must not glide is a seek or a new track: + * running the fill backwards across a second reads as a bug, not as an animation. + */ +describe('the spotify widget progress bar', () => { + const playing = (positionMs: number, extra: Record = {}) => ({ + available: true, state: 'playing', title: 'Song', artist: 'Artist', album: 'Album', + durationMs: 200_000, positionMs, ...extra, + }) + const scale = (loaded: Loaded) => + Number(/scaleX\(([\d.]+)\)/.exec(loaded.nodes.get('bar')!.style.transform ?? '')?.[1]) + + it('aims one poll ahead while the track plays', async () => { + const loaded = await loadWidget({}) + loaded.publish(playing(100_000)) + // 100 s of 200 s, plus the second it will have played by the next answer. + expect(scale(loaded)).toBeCloseTo(0.505, 5) + }) + + it('stops at where it is when the track is paused', async () => { + const loaded = await loadWidget({}) + loaded.publish(playing(100_000, { state: 'paused' })) + expect(scale(loaded)).toBeCloseTo(0.5, 5) + }) + + it('never aims past the end of the track', async () => { + const loaded = await loadWidget({}) + loaded.publish(playing(199_800)) + expect(scale(loaded)).toBe(1) + }) + + it('snaps rather than glides when the track changes', async () => { + const loaded = await loadWidget({}) + loaded.publish(playing(180_000)) + loaded.publish(playing(1_000, { title: 'Another' })) + // The snap class is put on and taken off within the call; what it leaves is the new aim. + expect(loaded.nodes.get('bar')!.className).toBe('') + expect(scale(loaded)).toBeCloseTo(0.01, 5) + }) + + it('snaps on a seek backwards, and glides on one forwards', async () => { + const loaded = await loadWidget({}) + loaded.publish(playing(100_000)) + loaded.publish(playing(20_000)) + expect(scale(loaded)).toBeCloseTo(0.105, 5) + }) + + it('draws an empty bar for a track with no duration at all', async () => { + const loaded = await loadWidget({}) + loaded.publish(playing(0, { durationMs: 0 })) + expect(scale(loaded)).toBe(0) + }) +}) diff --git a/widgets/spotify/index.html b/widgets/spotify/index.html index 80ca7bb..d704a32 100644 --- a/widgets/spotify/index.html +++ b/widgets/spotify/index.html @@ -5,7 +5,12 @@ .wrap{box-sizing:border-box;height:100%;padding:14px 18px;position:relative;display:flex;flex-direction:column;justify-content:center;gap:6px} .big{font-size:calc(44px * var(--text-scale, 1));font-weight:600;line-height:1} .bar{height:8px;border-radius:4px;background:var(--surface-3, #252a32);overflow:hidden} - .bar>i{display:block;height:100%;background:var(--accent, #d9b36a);width:0;transition:width .4s} + /* Scaled rather than resized: the position only arrives once a second, so the fill glides + across that second on the compositor instead of stepping on each answer. */ + .bar>i{display:block;height:100%;width:100%;background:var(--accent, #d9b36a); + transform:scaleX(0);transform-origin:left center;transition:transform 1s linear} + /* A new track, or a seek: there is nothing to glide across, so it lands where it is. */ + .bar>i.snap{transition:none} .row{display:flex;justify-content:space-between;font-size:calc(15px * var(--text-scale, 1))} .muted{color:var(--text-muted, #8b93a0)} button{font:inherit;border:0;border-radius:10px;background:var(--surface-3, #252a32);color:var(--text, #e6e8eb);padding:10px 14px;font-size:calc(18px * var(--text-scale, 1))} @@ -19,29 +24,89 @@ html.accent-fill .bar>i{background:var(--on-surface,#0b0d10)}
-
+
+
+
-
+