Skip to content

spotify: a progress bar that works, and a tile that fits its size - #4

Merged
fdussert merged 2 commits into
fdussert:mainfrom
mcouzinet:spotify-progress-and-layout
Sep 22, 2026
Merged

fdussert merged 2 commits into
fdussert:mainfrom
mcouzinet:spotify-progress-and-layout

Conversation

@mcouzinet

Copy link
Copy Markdown
Contributor

What this changes

Three things about the Spotify tile.

The progress 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, so the bar sat at zero — on the machine this is most likely to be running. AppleScript rounds the position to whole milliseconds now; an integer has no separator to get wrong. duration was already in milliseconds and sound volume already whole, which is why neither was affected.

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, so nothing drifts. 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 transport: at 4px under a 4px gap it read as an underline on the buttons 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, the default — the cover beside the song and the transport together;
  • header, from 440×340 — the cover beside the song alone, the transport across the whole width underneath;
  • column, when the tile is square or taller than wide — the cover on top, taking the height the rest leaves.

minSize drops to [8, 4], like every widget that is not a list of text, and defaultSize grows a row to [16, 5]. The max-width:480px rule already in the file was written for the narrow case but sat behind a width no tile could reach, since the widget asked for sixteen cells.

Two things there were worth measuring rather than guessing, and both cost me a round:

  • 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;
  • the type reads from whichever dimension the tile has to spare and is held back by the other. Sized on height alone a 950×280 tile carried a 21px title where it had room for thirty; sized on width alone a 2560×120 banner would ask for 76px, which does not fit under it.

How to see it

pnpm dev, play something in Spotify, and watch the bar cross a second instead of jumping.

For the arrangements, a tile at 16×5, 16×8, 16×12 and 8×12 on one page walks through all three. I swept widths from 8 to 64 cells against heights from 4 to 17 — about forty combinations — by driving the widget in iframes sized like tile bodies, with the same two messages the dashboard posts.

One size stays imperfect and I left it so: 8×8 (312×282 of body) keeps about 35px of margin top and bottom. It is too tall for a row to fill and too short for a column to be worth it — stacking would drop the cover from 143px to 77.

Checks

  • pnpm typecheck and pnpm test pass (1206 server, 334 ui)
  • pnpm --filter ui build run
  • widgets rescanned — the manifest changed
  • user-facing strings in French and English — none added
  • nothing personal anywhere

Branched on main at 2a16496. The widget's version goes to 1.1.0.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@fdussert

Copy link
Copy Markdown
Owner

Thanks — read the whole thing, ran the three test files against the branch (41/41), and ran your new STATE body against the Spotify on this Mac: paused|…|467692|222934|67, integer, right shape. Nothing blocking. Four things I would fix before merging, all small, then nits you can take or leave.

Should fix

1. The two "snaps" tests do not test the snap. server/test/spotify-widget.test.ts:186 and :193 assert the final aim and className === '' — which is the state the code ends in whether it snapped or not. I deleted the whole if (snap) { … } block (widgets/spotify/index.html:152-157) and all eleven tests still pass. Make the style stub record every write (push [className, transform]) and assert the sequence: ['snap', 'scaleX(0.9)'] then ['', 'scaleX(0.01)'] on a track change, a single write on a normal tick.

2. The server is still one separator away from the same silent NaN — and a test now pins that. server/src/providers/spotify.ts:34 positionMs: Number(position) is unchanged; the fix lives only in the AppleScript string. And server/test/providers.test.ts:33-36 asserts Number.isNaN(french.positionMs) === true, so whoever hardens the parser later has to delete a test. Note that assertion does not fail on main either (Math.round(NaN) is NaN), so the only real before/after in the suite is the two string matches on STATE. Suggest const num = (v: string) => Number(v.replace(',', '.')) for the three numeric fields, and the French case asserting positionMs === 69725. Then it genuinely fails before and passes after.

3. prefers-reduced-motion. index.html:9-10 adds a permanent 1 s animation that runs as long as music plays; it is the first widget that needs the media query. One rule next to .snap: @media (prefers-reduced-motion: reduce) { .bar > i { transition: none } }.

4. round inside tell application "Spotify" resolves through the tell target to a Standard Additions command. It works (verified live), but a target that refuses to load the addition errors the whole script, which would turn "bar stuck at zero" into "Spotify error". ((player position) * 1000) div 1 uses only operators and cannot fail that way; the toContain in the test just follows.

Nits

  • POLL_MS = 1000 (index.html:141) duplicates intervalMs: 1000 (spotify.ts:68) with nothing linking them; a comment naming the other, or the interval published in the snapshot, keeps the glide honest if one changes.
  • Pausing rewinds the fill by a few pixels: playing aims at the next second's position, the next answer is paused at ~200 ms later, not "backwards" per the condition, so the target drops and the fill runs back for a second. || (wasPlaying && d.state !== 'playing') in the snap condition.
  • widget-catalog.test.ts:125-153 pins minSize/defaultSize for five widgets but not spotify; the new [8,4]/[16,5] is the change most likely to be reverted by accident — one more line there.
  • render() returns early for stopped/!available/error without touching the bar, so a closed Spotify shows "Spotify fermé" over a leftover fill. Pre-existing, but this PR is the natural place to zero it.
  • On the body: this machine is en_FR and AppleScript answers 222.934005737305 with a dot — the trigger is the number format setting, not the region. Same fix, slightly narrower audience.

Checked and fine

No shell anywhere (osascript through the runner, open -b with a fixed argv); not-running / stopped shapes unchanged, so missing value cannot reach the new expression; duration and sound volume are integers and rightly left alone; the layout is pure media queries — no observer, no timer, nothing left running when the tile is hidden; minSize drops so no placed instance falls under it, and the catalogue test is green on the real folder; .controls button { min-height: 44px } holds in all three arrangements; cover caps identical on both axes as described; no new hardcoded colour, var(--text-scale, 1) everywhere; the long-press-opens-the-app handlers live on document, so the regrouped markup does not touch them; textContent only; nothing personal; commit message per AGENTS.md.

Once 1–4 are in I merge it, and spotify stays built in (it needs no connection, it is one of the ones that does).

What the review found.

**The snap tests could not see a snap.** They asserted where the fill ended, which is the same
whether it snapped or glided: deleting the whole `if (snap)` block left every test green. The
style stub now records each transform with the class it was written under, so a track change
reads as `snap` then a glide, a pause as a landing, and an ordinary answer as a single write.
Without the snap block, four of them fail.

**The parser no longer pins NaN.** The fix lived only in the AppleScript string, and a test
asserted the NaN the parser would still make of a comma. Duration, position and volume now go
through one helper that reads a decimal comma, exponent form included — from 10 000 up
AppleScript writes `6,9724998E+4` — and rounds to a whole millisecond. What decides the comma is
the number format setting, not the region.

**`div` replaces `round`.** Inside the tell block `round` is a Standard Additions command sent to
Spotify, and a Spotify that refused it would fail the whole script, turning a stuck bar into an
error. `div` is an operator; a test keeps `round` out.

**And the rest.** The glide stops under `prefers-reduced-motion`. A pause lands instead of
running the fill back. A closed, stopped or failing Spotify empties the bar and forgets the last
track — without that, a reopened Spotify left the fill stuck in `snap` and it never glided again.
The three copies of "one second" (the poll period, `POLL_MS`, the CSS transition) name each
other, and the catalogue test pins the new sizes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fdussert added a commit that referenced this pull request Sep 22, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@fdussert
fdussert merged commit c93f54b into fdussert:main Sep 22, 2026
1 check passed
@fdussert

Copy link
Copy Markdown
Owner

Merged — thanks. Checked the follow-up against main (it merges clean, typecheck and both suites green: 382 ui, 1346 server), and the four snap tests really do see it now: with the if (snap) block deleted, four of fourteen fail. The comma parser, div, reduced motion, the pause landing and the empty bar on a closed Spotify are all as described.

Two things I did on top, in main: the merge commit, and a CHANGELOG entry under Unreleased crediting you (CHANGELOG.md arrived after you branched; from now on a PR adds its own line — see AGENTS.md). Spotify stays built in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants