diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index 0ca8631..b2613ec 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -169,3 +169,9 @@ {"schema":3,"kind":"claim","id":"c24","text":"Spotify 1.2.74 on Linux ignores an MPRIS Volume write while paused: set-property Volume d 0.5 reads back 1.0 a second later","state":"accepted","ts":"2026-10-12T11:53:35+00:00","author":"claude-code","session":"session_01BEQogNE1Zk8GftfwcMBNAN","branch":"feat/music-dialog","scope":["hooks/music/reader.ts"],"rationale":"decides whether the dialog offers a local volume bar on Linux","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"busctl on the owner's machine, 2026-10-12"}],"revisit":"a Spotify release that honours Volume writes, or a check while playing","cost_if_wrong":"a volume bar that does nothing on Linux, or one hidden where it would work","pinned":false} {"schema":3,"kind":"decision","id":"d123","text":"Music dialog layout breakpoints: the 20x10 cover needs 20 body rows","state":"adopted","ts":"2026-10-13T06:21:49+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-dialog","scope":["hooks/musicplayer.tsx"],"rationale":"on macOS the left column under a 20x10 cover needs about 20 rows (cover, gap, three track rows, seek and volume bars, controls margin, up to three wrapped button rows), so 18 rows clipped the controls","supports":[["c23"]],"depends_on":[],"answers":[],"supersedes":["d122"],"evidence":[],"revisit":"","cost_if_wrong":"Linux at 18-19 rows shows the 8x4 cover where 20x10 would fit","pinned":false,"supersede_reason":"revise","choice":"Side by side from 80 granted columns; the cover is 20x10 from 20 body rows, 8x4 from 10, none below 10","alternatives":[],"decided_by":""} {"schema":3,"kind":"decision","id":"d124","text":"/glowup music toggles the music dialog","state":"adopted","ts":"2026-10-13T06:21:49+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-dialog","scope":["hooks/register.tsx"],"rationale":"the test engine cannot raise Esc, and the toggle matches an existing command","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"a Close button instead","pinned":false,"choice":"Running /glowup music while the dialog is shown closes it, as /glowup pane does","alternatives":[],"decided_by":""} +{"schema":3,"kind":"claim","id":"c25","text":"On Claude Code 2.1.296 a mod's process.spawn exposes no pid, signal or process group, so a mod cannot kill a spawned helper's children","state":"unassessed","ts":"2026-10-13T11:08:10+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-connect","scope":[],"rationale":"decides what Cancel can promise for spotify-login","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"a cancelled login could be stopped and the docs overstate the limit","pinned":false} +{"schema":3,"kind":"claim","id":"c26","text":"On Claude Code 2.1.296 the dialog focus ring keeps a position in the element order, not an element; after a step change it can land on nothing and the next typed letter goes to the composer; autoFocus applies only when a site takes the keys","state":"unassessed","ts":"2026-10-13T11:08:11+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-connect","scope":[],"rationale":"decides how the connect dialog moves focus between steps","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"fields that cannot be reached from the keyboard","pinned":false} +{"schema":3,"kind":"decision","id":"d125","text":"A failed Spotify Web API write falls back to the local app only on a 429","state":"adopted","ts":"2026-10-13T11:08:11+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-connect","scope":["hooks/register.tsx"],"rationale":"after a 5xx or a timeout the write may have landed; a local Next would skip twice and MPRIS PlayPause would toggle an API pause back to playing","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"a press during an API outage needs a second press","pinned":false,"choice":"429 and explicit refusals fall back to the local app; a 5xx, a timeout or a busy endpoint shows 'Spotify is not answering right now. Try again.'","alternatives":["fall back to local on any failed write"],"decided_by":""} +{"schema":3,"kind":"decision","id":"d126","text":"Each Spotify login gets its own paste file","state":"adopted","ts":"2026-10-13T11:08:11+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-connect","scope":["hooks/music/tokens.ts"],"rationale":"a cancelled login's helper lives on for minutes and would consume the next login's pasted address","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"stray paste files after a crash","pinned":false,"choice":"spotify-paste--, removed with its .tmp when the login ends or is cancelled","alternatives":["one shared spotify-paste file"],"decided_by":""} +{"schema":3,"kind":"decision","id":"d127","text":"A Spotify Web API endpoint stays busy until the engine request settles","state":"adopted","ts":"2026-10-13T11:08:11+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-connect","scope":["hooks/music/spotify.ts"],"rationale":"the engine request cannot be aborted, so freeing the key at the timeout let requests pile up one per 5 s","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"an engine request that never settles blocks that endpoint until reload","pinned":false,"choice":"SpotifyClient holds an endpoint's key until FetchInit.onSettled fires; the caller still gets offline after 5 s and falls back to local","alternatives":["free the key at the 5 s timeout"],"decided_by":""} +{"schema":3,"kind":"decision","id":"d128","text":"The connect dialog moves focus to the paste field with ui.focus when the waiting step draws","state":"adopted","ts":"2026-10-13T11:08:12+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-connect","scope":["hooks/register.tsx","hooks/musicplayer.tsx"],"rationale":"the focus ring keeps its position across the step change and landed on nothing","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"the paste field is unreachable from the keyboard over SSH","pinned":false,"choice":"startLogin calls $.ui.focus on the paste Input after publishing the waiting step; Copy address draws below the field","alternatives":[],"decided_by":""} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1389e46..1188dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `/glowup music`, or `o: Player` on the Music tab, opens a music player: a pane with a bigger cover, the seek bar, the controls, Open Spotify (`o`) and Copy link (`c`), and on macOS a volume bar. +- `/glowup spotify connect` connects your own Spotify developer app (Premium needed). The Music tab and player then follow playback on any device, show a heart to like the track (`l`) and the next track in your queue, and fall back to the local app while the Web API is not answering. Windows gets the player this way. See docs/spotify.md. ## [1.1.0] - 2026-10-12 diff --git a/docs/commands.md b/docs/commands.md index 7051b70..e908bfb 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -180,6 +180,10 @@ Setting `theme` back to `classic`, `spinner` to `pack` or `statusline` to its de `/glowup music` opens the music player, a pane that takes the keyboard; Esc closes it, and running the command again while the player is open closes it too, as `/glowup pane` does. When there is no room for the player, the command prints `Music player waits: `. See [Layout](layout.md#music-player). Opening it from the Music tab's `o: Player` closes the glowup pane while the player is up and brings it back when the player closes. In a `-p` run it prints the help. +## spotify + +`/glowup spotify` says whether glowup reads the Spotify app on this computer or your account through the Web API. `/glowup spotify connect` opens the music player at the connect steps (in a `-p` run it says it needs an interactive session); `/glowup spotify disconnect` deletes the saved login. See [Connect Spotify](spotify.md). + ## motion | Command | What it does | diff --git a/docs/layout.md b/docs/layout.md index 4dc9b77..11f47ae 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -112,10 +112,11 @@ The track playing in the Spotify desktop app, in a `NOW PLAYING` box. The tab st - The box shows the cover beside the title, the artist and album, and a progress bar. Below 40 columns the cover is left out. The cover is Spotify's 64 px image, drawn in half-block pixels by glowup's helper binary, which glowup downloads the first time it needs one (the same binary `/glowup config` uses). Until it arrives, or if it cannot, the box shows a `♪`. - Click the progress bar to jump to that point, or drag along it and let go. -- Below the box: `r: Prev`, `p: Pause` (`Play` while paused), `n: Next`, `s: ⇄` (shuffle), `e: ↻` (repeat, off and on) and `o: Player`. Shuffle and repeat are macOS only: Spotify's Linux client ignores repeat and shuffle-on changes over MPRIS, so those buttons are left out there. Shuffle and repeat are bright when on and dim when off, and are left out when the player does not report them. Click a button, or press its key while the pane has the keyboard. They act on the Spotify on this machine. +- Below the box: `r: Prev`, `p: Pause` (`Play` while paused), `n: Next`, `s: ⇄` (shuffle), `e: ↻` (repeat, off and on) and `o: Player`. Shuffle and repeat are macOS only: Spotify's Linux client ignores repeat and shuffle-on changes over MPRIS, so those buttons are left out there. Shuffle and repeat are bright when on and dim when off, and are left out when the player does not report them. Click a button, or press its key while the pane has the keyboard. They act through the Web API when [Spotify is connected](spotify.md), otherwise on the Spotify app on this machine. +- With [Spotify connected](spotify.md), a heart ends the title row: `♥` when the track is in your library, `♡` when not. `l: ♥` below toggles it. The third row shows `Up next: — `, or `on: ` when another device plays. The header reads `Spotify (local)` while the Web API is not answering and glowup shows the app on this computer. While the Web API runs the player, shuffle and repeat (every mode) are available on Linux too. - `o: Player` opens the music player, a focused pane with a bigger cover. Esc closes it and gives the keys back to the glowup pane. - An ad shows `Ad` in place of the track. With Spotify closed the tab says `Open Spotify to see what's playing.` -- glowup reads the player locally, every second while the tab is open and playing, otherwise every 2 to 10 seconds; the bar moves on its own between reads. On Linux it uses `busctl` (systemd) and falls back to `playerctl`; when neither is installed the tab says what to install. On macOS it uses `osascript`, and only asks when Spotify is already running, so glowup never launches it. Windows is not supported. +- glowup reads the player locally, every second while the tab is open and playing, otherwise every 2 to 10 seconds; the bar moves on its own between reads. On Linux it uses `busctl` (systemd) and falls back to `playerctl`; when neither is installed the tab says what to install. On macOS it uses `osascript`, and only asks when Spotify is already running, so glowup never launches it. Windows needs [Spotify connected](spotify.md). - While Spotify plays, the pet dances with a speaker beside it, and wears headphones and dances while Claude works. Custom pets without a dance use their idle and working poses. `/glowup setup pet.dance off` turns this off. - The compact form is one line: `♪ title — artist ▶`. @@ -123,8 +124,8 @@ The track playing in the Spotify desktop app, in a `NOW PLAYING` box. The tab st `/glowup music`, or `o` on the Music tab, opens the player. It shows the cover at 20 by 10 cells when the pane has 20 body rows or more, 8 by 4 from 10 rows, and none below 10. It also shows the title, artist and album, and the seek bar, which you click or drag as on the tab. Below it are the same Prev, Play, Next, shuffle and repeat keys, `o: Open Spotify` to bring Spotify's window forward (on Linux with busctl, and on macOS), and `c: Copy link` to copy the track's `open.spotify.com` link. -On macOS a volume bar sits under the seek bar: click or drag it, or click it and use the left and right arrows. Spotify 1.2.74 on Linux ignored a volume write over MPRIS (observed while paused), so Linux has no volume bar. +On macOS a volume bar sits under the seek bar: click or drag it, or click it and use the left and right arrows. Spotify 1.2.74 on Linux ignored a volume write over MPRIS (observed while paused), so Linux has no volume bar unless [Spotify is connected](spotify.md) and its Web API runs the player. -From 80 columns the player sits beside a second column, which will hold your queue, recent tracks, playlists and devices once you connect Spotify. Narrower, the two stack. While the player is open, toasts wait until it closes; anything the player has to tell you, such as a copied link, shows inside it. +From 80 columns the player sits beside a second column; narrower, the two stack. Until you connect Spotify, the column offers `k: Connect`, which walks through [connecting](spotify.md) in place. Once connected it shows your account, Up next and the device playing; the queue, recent tracks, playlists and devices come in a later release. While the player is open, toasts wait until it closes; anything the player has to tell you, such as a copied link or a refused command, shows inside it. The tabs do not respond to selection yet; opening a subagent's tool calls is on the [roadmap](roadmap.md). diff --git a/docs/meta.json b/docs/meta.json index 00ac101..932f426 100644 --- a/docs/meta.json +++ b/docs/meta.json @@ -1,4 +1,4 @@ { "title": "glowup", - "pages": ["---Start---", "install", "---Use---", "commands", "layout", "statusline", "pets", "pet-sprites", "---Packs---", "packs", "pack-reference", "---Themes---", "themes", "theme-reference", "---More---", "accessibility", "roadmap"] + "pages": ["---Start---", "install", "---Use---", "commands", "layout", "spotify", "statusline", "pets", "pet-sprites", "---Packs---", "packs", "pack-reference", "---Themes---", "themes", "theme-reference", "---More---", "accessibility", "roadmap"] } diff --git a/docs/spotify.md b/docs/spotify.md new file mode 100644 index 0000000..6139413 --- /dev/null +++ b/docs/spotify.md @@ -0,0 +1,58 @@ +--- +title: Connect Spotify +description: Connect your own Spotify developer app so the Music tab and player show your queue, likes and phone playback. +--- + +Without connecting, glowup reads the Spotify app on this computer. Connecting your Spotify account through the Web API adds: + +- a heart on the Music tab and in the player, to like the track playing (`l`) +- Up next, the first track in your queue +- playback on any device (your phone, a speaker, another computer), with the tab and the controls following it +- shuffle, every repeat mode and volume while the Web API runs the player, including on Linux, where Spotify's app ignores shuffle-on, repeat and (in 1.2.74, while paused) volume writes from glowup +- the player on Windows, where glowup cannot read the local app + +## What you need + +- **Spotify Premium.** Spotify lets an app control playback only for Premium accounts, and a developer app in development mode only for a Premium owner. +- **A free Spotify developer app of your own.** Spotify no longer gives one app to many people: an app in development mode serves up to five people its owner adds, so glowup cannot ship a shared one. Making your own takes a couple of minutes. + +## Connect + +1. Run `/glowup spotify connect`. The music player opens with the steps in its right column. The Connect button (`k`) in that column opens the same steps. +2. Open https://developer.spotify.com/dashboard, log in, and create an app. Any name and description will do. +3. Tick **Web API**. +4. Add the redirect URI `http://127.0.0.1/callback`, exactly so: `127.0.0.1`, not `localhost`, and no port. Save. +5. Copy the app's **Client ID** into the player's Client ID field and press Enter. It is 32 hexadecimal characters. +6. Your browser opens Spotify's consent page. Agree, and the player says **Connected as** your name. + +In the steps, `d` copies the dashboard address and `u` the redirect URI. The connect steps need an interactive session; in a `-p` run the command says so. + +### Over SSH, or with the browser on another machine + +After you agree, the browser goes to `http://127.0.0.1:/callback?…`, which only the machine running glowup can open, so it shows an error page. Copy that whole address from the browser's address bar into **Or paste the address the browser ended on** and press Enter. glowup checks that it came from this login before using it, and shows the reason in the player if it did not. + +While glowup waits, `w` copies the consent page's address in case the browser did not open by itself. The login gives up after five minutes. + +### Cancelling + +`x` cancels the login, and closing the player does the same. glowup then stops waiting, but the helper process behind the login cannot be stopped from inside glowup and may keep waiting for the browser until its five minutes are up. If you agree on Spotify's page in that time anyway, the login still completes and writes the file, and glowup connects at its next start. + +## Where the login lives + +glowup keeps the login in `~/.claude/glowup/spotify.json` (under `$CLAUDE_CONFIG_DIR` if you set it), readable only by you (mode 0600). Only glowup's helper program, `glowup-installer`, writes that file, and the login never goes into glowup's settings store, the status line or a log. For your account glowup talks only to `accounts.spotify.com` and `api.spotify.com`; covers come from Spotify's image server, as they do without connecting. + +## Disconnect + +`/glowup spotify disconnect` deletes the file, and glowup goes back to the local app. To revoke glowup entirely, delete the app on the developer dashboard too. + +`/glowup spotify` says which you are on: the app on this computer, or your account (with the start of the Client ID and your name), or that you need to connect again. + +## When something is off + +- **`Spotify (local)` in the Music tab's header.** The Web API is not answering (offline, or Spotify having trouble), so glowup shows the app on this computer until it does. Nothing to do. +- **"Spotify playback control needs Premium, so glowup uses the Spotify app on this computer for it."** Spotify answered that the account is not Premium. For the rest of the session transport (play, pause, skip, seek) goes to the local app; the heart and Up next still use the Web API. On Linux, shuffle, repeat and volume are back to what the local app takes. +- **"Nothing is playing. Pick a device."** No device has Spotify active. Start something in any Spotify app. +- **"Spotify needs you to connect again."** The login expired or you removed glowup's access. Run `/glowup spotify connect`. +- **"…was saved by a newer glowup".** A newer glowup wrote the login file. Update glowup; this one leaves the file alone and uses the local app. +- **"…is damaged. /glowup spotify disconnect removes it; then connect again."** The login file cannot be read as a login. Run `/glowup spotify disconnect`, then `/glowup spotify connect`. +- **The browser says `INVALID_CLIENT: Invalid redirect URI`.** The app's redirect URI is not exactly `http://127.0.0.1/callback`. Fix it on the dashboard and connect again. diff --git a/hooks/command.ts b/hooks/command.ts index 33dac6b..db5446e 100644 --- a/hooks/command.ts +++ b/hooks/command.ts @@ -45,6 +45,8 @@ export type Ctl = { openConfigTui(): Promise // opens the Music dialog; returns what to print openPlayer(): Promise + // the Spotify Web API connection: where it stands, the connect flow in the Music dialog, or removing it; returns what to print + spotify(action: 'status' | 'connect' | 'disconnect'): Promise // a -p run: nobody is attached to any surface headless(): Promise fields(): readonly FieldToken[] @@ -342,6 +344,11 @@ export async function runCommand(host: Host, args: string, ctl: Ctl): Promise', 'switch the whole look'], diff --git a/hooks/host.ts b/hooks/host.ts index b4e40b7..790a778 100644 --- a/hooks/host.ts +++ b/hooks/host.ts @@ -1,6 +1,11 @@ // The engine as the other modules see it. The loader refuses $ passed across an // import, so register.tsx builds this from $ in hostOf. export type RunResult = { exitCode: number; stdout: string; stderr: string } +export type FetchInit = { method?: string; headers?: Record; body?: string; timeoutMs: number + // called once when the engine's request ends, answered or failed: after timeoutMs the promise has rejected but the request, which cannot be aborted, may still be out + onSettled?: () => void } +export type FetchResult = { status: number; headers: Record; text: string } +export type Spawned = { done: Promise; kill(): void } export type Host = { // env is set over the engine's own environment; timeoutMs defaults to the engine's 30 s run(argv: readonly string[], env?: Record, timeoutMs?: number): Promise @@ -13,6 +18,10 @@ export type Host = { // size is in bytes, for a file listFiles(path: string): Promise<{ name: string; size: number }[]> fetchText(url: string): Promise<{ ok: boolean; status: number; text: string }> + // rejects on a network error and once timeoutMs passes: the engine's fetch has no timeout of its own + fetch(url: string, init: FetchInit): Promise + // starts argv and hands its output over a line at a time; done is the exit code, null when killed or never started + spawn(argv: readonly string[], onLine: (stream: 'stdout' | 'stderr', line: string) => void): Spawned storeGet(key: string): Promise storeSet(key: string, value: unknown): Promise storeDelete(key: string): Promise diff --git a/hooks/launch.ts b/hooks/launch.ts index 4f15a27..0657f53 100644 --- a/hooks/launch.ts +++ b/hooks/launch.ts @@ -93,6 +93,10 @@ const WIN_FIND = WIN_WANT + 'if (-not $f) { exit 1 }' const WIN_CHECK = WIN_WANT + 'if (-not $f) { exit 1 }; if ((Get-FileHash -Algorithm SHA256 -LiteralPath "$env:GLOWUP_DIR/$env:GLOWUP_ASSET").Hash.ToLower() -ne $f[0].ToLower()) { exit 1 }' const WIN_MKDIR = 'New-Item -ItemType Directory -Force -Path $env:GLOWUP_PATH | Out-Null' const WIN_MOVE = 'Move-Item -LiteralPath $env:GLOWUP_FROM -Destination $env:GLOWUP_TO -Force' +// The engine's fs has no rename. Within one directory either command replaces the target whole, so a reader polling it +// never sees half a file. +export const moveFile = (host: Host, from: string, to: string, platform: Platform) => + platform === 'windows' ? host.run(pwsh(WIN_MOVE, {}).argv, { GLOWUP_FROM: from, GLOWUP_TO: to }) : host.run(['mv', from, to]) // the same match install.sh makes: the asset's own line, with or without the binary-mode star const FIND = 'cd "$1" && awk -v f="$2" \'$2 == f || $2 == "*" f { print $1 " " f }\' checksums.txt > want.txt && test -s want.txt' diff --git a/hooks/music/parse.ts b/hooks/music/parse.ts index 5b05777..9cf30ce 100644 --- a/hooks/music/parse.ts +++ b/hooks/music/parse.ts @@ -1,8 +1,17 @@ // JSX-free and engine-free: parsers only, so they test on recorded output. export type Repeat = 'off' | 'context' | 'track' export type Track = { id: string; title: string; artist: string; album: string; durationMs: number; artUrl?: string; url?: string; uri?: string } -// shuffle, repeat and volume (0..1) are absent when the player did not report them, and their controls hide -export type OpenMusic = { status: 'open'; track?: Track; playing: boolean; ad: boolean; progressMs: number; readAt: number; shuffle?: boolean; repeat?: Repeat; volume?: number } +export type Device = { id: string; name: string; type: string; isThisComputer: boolean } +export type QueueItem = { uri: string; title: string; artist: string; durationMs: number } +// shuffle, repeat and volume (0..1) are absent when the player did not report them, and their controls hide. +// source, device, liked and upNext are set only while Spotify is connected: source is api while the Web API +// answered within two polls, local while glowup falls back to the app on this computer. +export type OpenMusic = { + status: 'open'; track?: Track; playing: boolean; ad: boolean; progressMs: number; readAt: number; shuffle?: boolean; repeat?: Repeat; volume?: number + source?: 'api' | 'local'; device?: Device; liked?: boolean; upNext?: QueueItem + // set with source local when progressMs was raised above the local read's own position: that raw position + held?: number +} export type Music = { status: 'closed' } | { status: 'unsupported'; hint: string } | OpenMusic export type MusicCmd = | { cmd: 'playpause' | 'next' | 'previous' | 'raise' } @@ -10,6 +19,7 @@ export type MusicCmd = | { cmd: 'shuffle'; on: boolean } | { cmd: 'repeat'; mode: Repeat } | { cmd: 'volume'; level: number } + | { cmd: 'like'; on: boolean } export type Extras = Pick const isAd = (id: string) => id.startsWith('/com/spotify/ad/') || id.startsWith('spotify:ad:') @@ -146,5 +156,6 @@ export function sameMusic(a: Music | undefined, b: Music, now: number): boolean if (a.status === 'closed' || b.status !== 'open') return true const o = a as OpenMusic return o.playing === b.playing && o.ad === b.ad && o.shuffle === b.shuffle && o.repeat === b.repeat && o.volume === b.volume + && o.source === b.source && o.liked === b.liked && o.upNext?.uri === b.upNext?.uri && JSON.stringify(o.device) === JSON.stringify(b.device) && JSON.stringify(o.track) === JSON.stringify(b.track) && Math.abs(progressAt(o, now) - progressAt(b, now)) <= SEEK_MS } diff --git a/hooks/music/player.ts b/hooks/music/player.ts new file mode 100644 index 0000000..7c3d9ea --- /dev/null +++ b/hooks/music/player.ts @@ -0,0 +1,70 @@ +// Which Spotify state to show and who runs a command, from the local read and the Web API's last answers. +// JSX-free and engine-free, so it tests on recorded states with any clock. +import { progressAt, SEEK_MS, type Music, type MusicCmd, type OpenMusic, type QueueItem, type Repeat } from './parse.ts' +import type { MusicCaps } from './reader.ts' + +// What the API last said. liked and upNext are keyed by the track URI they were read for, so an answer about +// the last track never lands on this one; on or item absent marks a read that failed and is not tried again for it. +export type Link = { + // the last good GET /me/player; music absent is a 204. localUri is the local app's track and intervalMs the poll + // interval when it was read: a local read counts as a track change the API has not seen only if its URI differs + // from localUri, and the read stays fresh for two of the slower of intervalMs and the interval now. + last?: { at: number; music?: OpenMusic; localUri?: string; intervalMs?: number } + liked?: { uri: string; on?: boolean } + upNext?: { uri: string; item?: QueueItem } +} +export type Route = 'api' | 'apiThenLocal' | 'local' | 'none' +const ALL_REPEAT: Repeat[] = ['off', 'context', 'track'] + +export const apiDelay = (playing: boolean, shown: boolean) => (playing && shown ? 3000 : 15_000) + +// link undefined is not connected, or Reconnect: the local read as before A3. intervalMs is the API's poll interval now. +export function mergeMusic(local: Music, link: Link | undefined, prev: Music | undefined, now: number, intervalMs: number): Music { + if (!link) return local + const last = link.last + let out: Music + if (!last || now - last.at > 2 * Math.max(intervalMs, last.intervalMs ?? 0)) out = local.status === 'open' ? { ...local, source: 'local' } : local + else if (!last.music) out = local.status === 'open' ? { ...local, source: 'api' } : { status: 'open', playing: false, ad: false, progressMs: 0, readAt: now, source: 'api' } + else { + const api = last.music + const uri = local.status === 'open' ? local.track?.uri : undefined + // A local track other than the one local showed at the API read may be a change the API has not seen yet: local wins until the API reads again. + const here = uri !== undefined && api.device?.type === 'Computer' && (api.track?.uri === uri || uri !== last.localUri) + out = here + ? { ...(local as OpenMusic), source: 'api', ...(api.shuffle !== undefined && { shuffle: api.shuffle }), ...(api.repeat && { repeat: api.repeat }), ...(api.volume !== undefined && { volume: api.volume }), device: { ...api.device!, isThisComputer: true } } + : api + } + return keepProgress(withTrackFacts(out, link), prev, now) +} + +function withTrackFacts(m: Music, link: Link): Music { + if (m.status !== 'open' || m.source !== 'api' || !m.track?.uri) return m + const uri = m.track.uri + return { ...m, ...(link.liked?.uri === uri && link.liked.on !== undefined && { liked: link.liked.on }), ...(link.upNext?.uri === uri && link.upNext.item && { upNext: link.upNext.item }) } +} + +// A switch of source for the same track never pulls the bar back: one source can lag the other by a poll. When the +// API goes quiet the floor stays for as long as the local read is the one it was taken against (held): an idle or +// stale app reports the same position again, a changed one is a real seek or playback and is followed. +function keepProgress(m: Music, prev: Music | undefined, now: number): Music { + if (m.status !== 'open' || prev?.status !== 'open' || (m.source === 'api' && prev.source === 'api') || !m.track?.uri || m.track.uri !== prev.track?.uri) return m + if (m.source === 'local' && prev.source === 'local' && m.progressMs !== prev.held) return m + const was = progressAt(prev, now) + if (progressAt(m, now) >= was - SEEK_MS) return m + return { ...m, progressMs: Math.round(was), readAt: now, ...(m.source === 'local' && { held: m.progressMs }) } +} + +// premium false: Spotify answered PREMIUM_REQUIRED once this session, so transport stays local. +export function routeOf(cmd: MusicCmd, m: Music | undefined, premium: boolean): Route { + const o = m?.status === 'open' && m.source === 'api' && m.device ? m : undefined + if (cmd.cmd === 'like') return o?.track?.uri ? 'api' : 'none' + if (cmd.cmd === 'raise' || !o || !premium) return 'local' + // the API can fail where the app on this computer still answers + return o.device!.isThisComputer ? 'apiThenLocal' : 'api' +} + +// While the API runs the player, shuffle, every repeat mode and volume work whatever the local app allows. +export function capsOf(m: Music | undefined, local: MusicCaps | undefined, premium: boolean): MusicCaps | undefined { + const api = premium && m?.status === 'open' && m.source === 'api' && m.device !== undefined + return api ? { repeat: ALL_REPEAT, shuffle: true, raise: local?.raise ?? false, volume: true } : local +} diff --git a/hooks/music/reader.ts b/hooks/music/reader.ts index 0d6b95f..a750554 100644 --- a/hooks/music/reader.ts +++ b/hooks/music/reader.ts @@ -5,7 +5,7 @@ export type Platform = 'linux' | 'mac' | 'windows' export type Backend = 'busctl' | 'playerctl' | 'osascript' export type MusicCaps = { repeat: Repeat[]; shuffle: boolean; raise: boolean; volume: boolean } export const LINUX_HINT = 'Spotify needs busctl (systemd) or playerctl to show here.' -export const WINDOWS_HINT = 'Spotify shows here on Linux and macOS.' +export const WINDOWS_HINT = 'Connect Spotify to use the player on Windows.' const RUN_MS = 2000 const BUS_NAME = 'org.mpris.MediaPlayer2.spotify', BUS_PATH = '/org/mpris/MediaPlayer2' diff --git a/hooks/music/spotify.ts b/hooks/music/spotify.ts new file mode 100644 index 0000000..c63b6f6 --- /dev/null +++ b/hooks/music/spotify.ts @@ -0,0 +1,60 @@ +// The Web API client: the bearer token, one refresh and retry on a 401, Retry-After, and one request per endpoint in flight. +import type { Host } from '../host.ts' +import type { OpenMusic } from './parse.ts' +import type { TokenKeeper } from './tokens.ts' +import { apiError, parsePlayer, playerRequest, type ApiError, type ApiRequest } from './webapi.ts' + +export const API_TIMEOUT_MS = 5000 +export type ApiAnswer = { ok: true; status: number; text: string; at: number } | { ok: false; error: ApiError } + +export class SpotifyClient { + private inFlight = new Set() + private until = 0 + // false once a write answered PREMIUM_REQUIRED: transport then goes through the local app for the session + premium = true + constructor(private host: Host, private keeper: TokenKeeper, private now: () => number) {} + + async send(r: ApiRequest): Promise { + if (this.now() < this.until) return { ok: false, error: { kind: 'rate', untilMs: this.until } } + const key = `${r.method} ${r.url.split('?')[0]}` + if (this.inFlight.has(key)) return { ok: false, error: { kind: 'busy' } } + this.inFlight.add(key) + // The key is held until the engine's request ends, not until the answer: Host.fetch rejects at the timeout but + // cannot abort it, and a dead endpoint would otherwise get a new request every 5 s without bound. + let open = 1 + const hold = () => { open++; let done = false; return () => { if (!done && !--open) this.inFlight.delete(key); done = true } } + try { return await this.authorized(r, hold) } finally { if (!--open) this.inFlight.delete(key) } + } + + private async authorized(r: ApiRequest, hold: () => () => void): Promise { + const first = await this.keeper.access(this.now()) + if ('error' in first) return { ok: false, error: first.error === 'failed' ? { kind: 'offline' } : { kind: 'reconnect' } } + let res = await this.fetch(r, first.token, hold()) + if (res?.status === 401) { + const again = await this.keeper.access(this.now(), first.token) + if ('error' in again) return { ok: false, error: again.error === 'failed' ? { kind: 'offline' } : { kind: 'reconnect' } } + res = await this.fetch(r, again.token, hold()) + } + if (!res) return { ok: false, error: { kind: 'offline' } } + if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status, text: res.text, at: this.now() } + const error = apiError(res.status, res.text, res.headers, this.now()) + if (error.kind === 'rate') this.until = error.untilMs + if (error.kind === 'premium') this.premium = false + if (error.kind === 'reconnect') this.keeper.lost() + return { ok: false, error } + } + + private fetch(r: ApiRequest, token: string, onSettled: () => void) { + return this.host.fetch(r.url, { method: r.method, headers: { Authorization: `Bearer ${token}` }, timeoutMs: API_TIMEOUT_MS, onSettled }).catch(() => undefined) + } +} + +// music absent is a 204: nothing is active on any device. +export type PlayerRead = { ok: true; at: number; music?: OpenMusic } | { ok: false; error: ApiError } +export async function readPlayer(c: SpotifyClient): Promise { + const a = await c.send(playerRequest()) + if (!a.ok) return a + if (a.status === 204 || !a.text.trim()) return { ok: true, at: a.at } + const music = parsePlayer(a.text, a.at) + return music ? { ok: true, at: a.at, music } : { ok: false, error: { kind: 'server', status: a.status } } +} diff --git a/hooks/music/tokens.ts b/hooks/music/tokens.ts new file mode 100644 index 0000000..3c851e0 --- /dev/null +++ b/hooks/music/tokens.ts @@ -0,0 +1,71 @@ +// The Spotify token file. Only glowup-installer writes it (spotify-login, -refresh and -logout), so its mode +// stays 0600; this side reads it and asks the helper for a refresh. +import type { Host } from '../host.ts' +import { needsRefresh } from './webapi.ts' + +export const TOKENS_FORMAT = 1 +export type Tokens = { format: number; clientId: string; accessToken: string; refreshToken: string; expiresAt: number; scope: string; connectedAt: number; gen: number } +export type TokenRead = { ok: true; tokens: Tokens } | { ok: false; why: 'missing' | 'newer' | 'damaged' } +export const tokenFile = (configDir: string) => `${configDir}/glowup/spotify.json` +// spotify-login polls this for an address pasted into the dialog. One file per login: a cancelled login's helper +// lives on for minutes and would otherwise consume the next login's address. session is random per glowup process, +// since the engine exposes no pid and two sessions (or a crashed one's helper) must not share a name. +export const pasteFile = (configDir: string, session: string, seq: number) => `${configDir}/glowup/spotify-paste-${session}-${seq}` + +export async function readTokens(host: Host, path: string): Promise { + let text: string + try { text = await host.readFile(path) } catch { return { ok: false, why: 'missing' } } + let o: Record + try { o = JSON.parse(text) } catch { return { ok: false, why: 'damaged' } } + if (!o || typeof o !== 'object') return { ok: false, why: 'damaged' } + // spotify-login writes over whatever file is there, so connect's refusal of a newer file lives here; spotify-refresh refuses one itself. + if (typeof o.format === 'number' && o.format > TOKENS_FORMAT) return { ok: false, why: 'newer' } + const s = (v: unknown): v is string => typeof v === 'string' && v !== '' + const n = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) + if (o.format !== TOKENS_FORMAT || !s(o.clientId) || !s(o.accessToken) || !s(o.refreshToken) || !n(o.expiresAt)) return { ok: false, why: 'damaged' } + return { ok: true, tokens: { format: TOKENS_FORMAT, clientId: o.clientId, accessToken: o.accessToken, refreshToken: o.refreshToken, expiresAt: o.expiresAt, scope: typeof o.scope === 'string' ? o.scope : '', connectedAt: n(o.connectedAt) ? o.connectedAt : 0, gen: n(o.gen) ? o.gen : 0 } } +} + +// Runs `spotify-refresh --if-gen gen` and resolves its exit code: 0 refreshed (by this run or a racing one), 3 refused. +export type Refresher = (gen: number) => Promise +export type Access = { token: string } | { error: 'none' | 'newer' | 'damaged' | 'reconnect' | 'failed' } + +export class TokenKeeper { + private tokens: Tokens | undefined + private state: 'ok' | 'none' | 'newer' | 'damaged' | 'reconnect' = 'none' + private flight: Promise | undefined + constructor(private host: Host, readonly path: string, private refresher: Refresher) {} + + async load(): Promise { + const r = await readTokens(this.host, this.path) + this.tokens = r.ok ? r.tokens : undefined + this.state = r.ok ? 'ok' : r.why === 'missing' ? 'none' : r.why + return r + } + status() { return this.state } + // Spotify answered 401 to a token that was just refreshed. A newer or damaged file found meanwhile keeps its own state. + lost() { if (this.state === 'ok') this.state = 'reconnect' } + + // A token to send. It refreshes first in the token's last minute, or when rejected is the token Spotify just answered 401 to. + async access(now: number, rejected?: string): Promise { + const t = this.tokens + if (this.state !== 'ok' || !t) return { error: this.state === 'ok' ? 'none' : this.state } + const stale = rejected === undefined ? needsRefresh(t.expiresAt, now) : rejected === t.accessToken + if (!stale) return { token: t.accessToken } + // one refresh at a time in this session: callers that arrive meanwhile wait for the same one + this.flight ??= this.refresh(t, now, rejected !== undefined).finally(() => { this.flight = undefined }) + return this.flight + } + + private async refresh(t: Tokens, now: number, rejected: boolean): Promise { + const code = await this.refresher(t.gen).catch(() => 1) + // The helper is the only writer, so the file is the truth, even after exit 3: a racing session may have refreshed first. + const again = await readTokens(this.host, this.path) + if (again.ok && again.tokens.gen !== t.gen) { this.tokens = again.tokens; return { token: again.tokens.accessToken } } + // replaced by a newer glowup's file, or damaged, while the helper ran: stop spawning it + if (!again.ok && again.why !== 'missing') { this.state = again.why; return { error: again.why } } + if (code === 3) { this.state = 'reconnect'; return { error: 'reconnect' } } + // offline, or the helper failed: the old token while it lasts, unless Spotify already refused it + return !rejected && t.expiresAt > now ? { token: t.accessToken } : { error: 'failed' } + } +} diff --git a/hooks/music/webapi.ts b/hooks/music/webapi.ts new file mode 100644 index 0000000..d8073d0 --- /dev/null +++ b/hooks/music/webapi.ts @@ -0,0 +1,120 @@ +// JSX-free and engine-free: Spotify Web API requests as data, and its answers parsed into the shared Music shapes. +import type { Device, MusicCmd, OpenMusic, QueueItem, Repeat, Track } from './parse.ts' + +export const API = 'https://api.spotify.com/v1' +export type ApiRequest = { method: 'GET' | 'PUT' | 'POST' | 'DELETE'; url: string } + +// Without additional_types=episode, a podcast episode comes back as item: null. +export const playerRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me/player?additional_types=episode` }) +export const queueRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me/player/queue?additional_types=episode` }) +export const meRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me` }) +// /me/tracks was removed in February 2026; /me/library takes spotify: URIs +export const likedRequest = (uri: string): ApiRequest => ({ method: 'GET', url: `${API}/me/library/contains?uris=${encodeURIComponent(uri)}` }) +export const likeRequest = (uri: string, on: boolean): ApiRequest => ({ method: on ? 'PUT' : 'DELETE', url: `${API}/me/library?uris=${encodeURIComponent(uri)}` }) + +const unit = (v: number) => Math.min(1, Math.max(0, v)) + +// The write a transport command makes; playing picks pause or play. like goes through likeRequest, and raise is local only. +export function commandRequest(c: MusicCmd, playing: boolean): ApiRequest | undefined { + switch (c.cmd) { + case 'playpause': return { method: 'PUT', url: `${API}/me/player/${playing ? 'pause' : 'play'}` } + case 'next': return { method: 'POST', url: `${API}/me/player/next` } + case 'previous': return { method: 'POST', url: `${API}/me/player/previous` } + case 'seek': return { method: 'PUT', url: `${API}/me/player/seek?position_ms=${Math.max(0, Math.round(c.ms))}` } + case 'shuffle': return { method: 'PUT', url: `${API}/me/player/shuffle?state=${c.on}` } + case 'repeat': return { method: 'PUT', url: `${API}/me/player/repeat?state=${c.mode}` } + case 'volume': return { method: 'PUT', url: `${API}/me/player/volume?volume_percent=${Math.round(unit(c.level) * 100)}` } + default: return undefined + } +} + +// A minute early, so a token never expires between the check and Spotify reading it. +export const needsRefresh = (expiresAt: number, now: number) => expiresAt - now < 60_000 + +type Obj = Record +const obj = (v: unknown): Obj | undefined => (v && typeof v === 'object' && !Array.isArray(v) ? v as Obj : undefined) +const str = (v: unknown) => (typeof v === 'string' ? v : '') +const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0) +const json = (text: string): unknown => { try { return JSON.parse(text) } catch { return undefined } } +const REPEATS: readonly unknown[] = ['off', 'context', 'track'] + +// The narrowest picture with a known width: the cover helper shrinks it to 20 px at most. +function smallest(images: unknown): string | undefined { + const all = Array.isArray(images) ? images.map(obj).filter((i): i is Obj => !!i && typeof i.url === 'string') : [] + const sized = all.filter(i => num(i.width) > 0).sort((a, b) => num(a.width) - num(b.width)) + return str((sized[0] ?? all[0])?.url) || undefined +} + +// A track or an episode; the API names both by spotify: URI, which is also the id here. +function trackOf(v: unknown): Track | undefined { + const it = obj(v), uri = str(it?.uri) + if (!it || !uri) return undefined + const episode = it.type === 'episode', album = obj(it.album), show = obj(it.show) + const artist = episode ? str(show?.name) : (Array.isArray(it.artists) ? it.artists : []).map(a => str(obj(a)?.name)).filter(Boolean).join(', ') + const artUrl = smallest(episode ? it.images ?? show?.images : album?.images) + const url = str(obj(it.external_urls)?.spotify) + return { id: uri, title: str(it.name), artist, album: episode ? '' : str(album?.name), durationMs: Math.max(0, Math.round(num(it.duration_ms))), uri, ...(artUrl && { artUrl }), ...(url && { url }) } +} + +// GET /me/player's 200 body. readAt is when the answer arrived, not Spotify's timestamp, +// so another device's clock cannot move the bar. +export function parsePlayer(text: string, readAt: number): OpenMusic | undefined { + const o = obj(json(text)) + if (!o) return undefined + const d = obj(o.device) + const device: Device | undefined = d && str(d.name) ? { id: str(d.id), name: str(d.name), type: str(d.type), isThisComputer: false } : undefined + const track = trackOf(o.item) + const volume = d && typeof d.volume_percent === 'number' && d.supports_volume !== false ? unit(d.volume_percent / 100) : undefined + return { + status: 'open', playing: o.is_playing === true, ad: o.currently_playing_type === 'ad', progressMs: Math.max(0, Math.round(num(o.progress_ms))), readAt, source: 'api', + ...(track && { track }), + ...(typeof o.shuffle_state === 'boolean' && { shuffle: o.shuffle_state }), + ...(REPEATS.includes(o.repeat_state) && { repeat: o.repeat_state as Repeat }), + ...(volume !== undefined && { volume }), + ...(device && { device }), + } +} + +export function parseQueue(text: string): QueueItem[] | undefined { + const q = obj(json(text))?.queue + if (!Array.isArray(q)) return undefined + return q.map(trackOf).filter((t): t is Track => !!t).map(t => ({ uri: t.uri!, title: t.title, artist: t.artist, durationMs: t.durationMs })) +} + +export function parseLiked(text: string): boolean | undefined { + const a = json(text) + return Array.isArray(a) && typeof a[0] === 'boolean' ? a[0] : undefined +} + +export function parseMe(text: string): string | undefined { + const o = obj(json(text)) + return str(o?.display_name) || str(o?.id) || undefined +} + +export type ApiError = + // a network error, or no answer within the 5 s timeout + | { kind: 'offline' } + // the same endpoint still has a request out + | { kind: 'busy' } + // a 429: nothing goes out before untilMs + | { kind: 'rate'; untilMs: number } + // a 401 after a refresh, or the refresh token refused: the person must connect again + | { kind: 'reconnect' } + | { kind: 'premium' } + | { kind: 'noDevice' } + | { kind: 'refused'; reason: string } + | { kind: 'server'; status: number } + +// Spotify's error.reason tells PREMIUM_REQUIRED and NO_ACTIVE_DEVICE from other 403s and 404s. +export function apiError(status: number, text: string, headers: Record, now: number): ApiError { + if (status === 429) { + const s = Number(headers['retry-after']) + return { kind: 'rate', untilMs: now + (Number.isFinite(s) && s > 0 ? s : 5) * 1000 } + } + if (status >= 500) return { kind: 'server', status } + if (status === 401) return { kind: 'reconnect' } + const reason = str(obj(obj(json(text))?.error)?.reason) + if (status === 403 && reason === 'PREMIUM_REQUIRED') return { kind: 'premium' } + if (status === 404 && reason === 'NO_ACTIVE_DEVICE') return { kind: 'noDevice' } + return { kind: 'refused', reason: reason || String(status) } +} diff --git a/hooks/musicplayer.tsx b/hooks/musicplayer.tsx index cb45be3..31d8861 100644 --- a/hooks/musicplayer.tsx +++ b/hooks/musicplayer.tsx @@ -2,44 +2,64 @@ import type { Model } from './model.ts' import type { Theme } from './themes.ts' import { fit, renderSegs, type Seg } from './layout.tsx' -import { progressAt, type MusicCmd } from './music/parse.ts' +import { progressAt, type Music, type MusicCmd } from './music/parse.ts' import { barRow, type SeekBarProps } from './music/seek.ts' import { coverPlaceholder } from './music/art.ts' import type { MusicCaps } from './music/reader.ts' -import { musicControls } from './pane.tsx' +import { musicControls, upNextLine } from './pane.tsx' export const PLAYER_ID = 'glowup-music' +export const PASTE_INPUT = 'input-paste' // The dock grants about 88 columns and inline 13 to 20 rows, so these fit the grants seen, not the 110x24 asked for. const LEFT_COLS = 30, SIDE_MIN = 80, GAP = 2 // On macOS the left column under a 20x10 cover needs about 20 rows: cover, gap, three track rows, the seek and // volume bars, the controls margin and up to three wrapped button rows. export const playerCoverSize = (bodyRows: number): 0 | 8 | 20 => (bodyRows >= 20 ? 20 : bodyRows >= 10 ? 8 : 0) export const CONNECT_PITCH = ['Connect Spotify to see your queue, recent tracks, playlists and devices.', 'Needs Spotify Premium and a free developer app.'] as const +export const DASHBOARD_URL = 'https://developer.spotify.com/dashboard' +// Registered without a port: Spotify then accepts the helper's loopback redirect on any port. +export const REDIRECT_URI = 'http://127.0.0.1/callback' +export const CONNECT_STEPS = [`Open ${DASHBOARD_URL} and create an app.`, 'Tick "Web API".', `Add the redirect URI ${REDIRECT_URI}.`, 'Paste the Client ID below.'] as const + +// The right column: the pitch, the connect flow's two steps, and the resting states it ends in. +export type AccountView = + | { step: 'pitch' } + | { step: 'steps'; error?: string } + | { step: 'waiting'; authUrl?: string; notice?: string } + | { step: 'connected'; name?: string } + | { step: 'reconnect' } + | { step: 'refused'; text: string } +type Els = { Box: any; Text: any; Button: any; Input?: any } export type PlayerNote = { text: string; tone: 'ok' | 'error' } -export type PlayerView = { width: number; bodyRows: number; cover?: Seg[][]; caps?: MusicCaps; note?: PlayerNote } -export type PlayerExtra = { onMusic: (cmd: MusicCmd) => void; onCopy: (url: string) => void; seek?: (props: SeekBarProps) => unknown; volume?: (props: SeekBarProps) => unknown } +export type PlayerView = { width: number; bodyRows: number; cover?: Seg[][]; caps?: MusicCaps; note?: PlayerNote; account?: AccountView } +export type PlayerExtra = { + onMusic: (cmd: MusicCmd) => void; onCopy: (url: string) => void; seek?: (props: SeekBarProps) => unknown; volume?: (props: SeekBarProps) => unknown + onConnect?: () => void; onClientId?: (id: string) => void; onPaste?: (text: string) => void; onCancel?: () => void +} -export function renderPlayer(els: { Box: any; Text: any; Button: any }, m: Model, t: Theme, v: PlayerView, now: number, extra: PlayerExtra) { +// State, note and account lines wrap, since a 30-column left column truncates "Open Spotify to see what's playing." +const wrapped = (els: Els, text: string, color: string, key: string) => {text} + +export function renderPlayer(els: Els, m: Model, t: Theme, v: PlayerView, now: number, extra: PlayerExtra) { const { Box, Text, Button } = els const c = t.colors, s = m.music ?? { status: 'closed' as const } const side = v.width >= SIDE_MIN const lw = side ? LEFT_COLS : v.width const row = (segs: Seg[], key: string) => renderSegs(els, fit(segs, lw), key) - // State and note lines wrap, since a 30-column left column truncates "Open Spotify to see what's playing." - const wrapped = (text: string, color: string, key: string) => {text} const left: unknown[] = [] - if (s.status === 'unsupported') left.push(wrapped(s.hint, c.dim, 'state')) - else if (s.status === 'closed') left.push(wrapped("Open Spotify to see what's playing.", c.dim, 'state')) - else if (s.ad) left.push(wrapped('Ad', c.dim, 'state')) - else if (!s.track) left.push(wrapped('Nothing playing.', c.dim, 'state')) + if (s.status === 'unsupported') left.push(wrapped(els, s.hint, c.dim, 'state')) + else if (s.status === 'closed') left.push(wrapped(els, "Open Spotify to see what's playing.", c.dim, 'state')) + else if (s.ad) left.push(wrapped(els, 'Ad', c.dim, 'state')) + else if (!s.track) left.push(wrapped(els, 'Nothing playing.', c.dim, 'state')) else { const track = s.track, size = playerCoverSize(v.bodyRows) if (size > 0) { ;(v.cover ?? coverPlaceholder(size, size / 2, c.accent)).forEach((r, i) => left.push(renderSegs(els, r, 'cover' + i))) left.push(renderSegs(els, [], 'cover-gap')) } - left.push(row([{ text: track.title, color: c.text, bold: true }], 'title')) + const heart: Seg[] = s.liked === undefined ? [] : [{ text: ' ' + (s.liked ? '♥' : '♡'), color: s.liked ? c.accent : c.dim }] + left.push(row([...fit([{ text: track.title, color: c.text, bold: true }], lw - (heart.length ? 2 : 0)), ...heart], 'title')) if (track.artist) left.push(row([{ text: track.artist, color: c.dim }], 'artist')) if (track.album) left.push(row([{ text: track.album, color: c.dim }], 'album')) const colors = { text: c.text, accent: c.accent, faint: c.faint, dim: c.dim } @@ -55,11 +75,11 @@ export function renderPlayer(els: { Box: any; Text: any; Button: any }, m: Model // wraps at the column's width, so a narrow column takes two rows of buttons rather than overflowing left.push({buttons}) } - if (v.note) left.push(wrapped(v.note.text, v.note.tone === 'error' ? c.fail : c.dim, 'note')) + if (v.note) left.push(wrapped(els, v.note.text, v.note.tone === 'error' ? c.fail : c.dim, 'note')) const rw = side ? Math.max(1, v.width - LEFT_COLS - GAP) : v.width const right = ( - {CONNECT_PITCH.map((l, i) => {l})} + {accountRows(els, t, v.account ?? { step: 'pitch' }, s, extra) as any} ) return ( @@ -69,3 +89,54 @@ export function renderPlayer(els: { Box: any; Text: any; Button: any }, m: Model ) } + +function accountRows(els: Els, t: Theme, a: AccountView, s: Music, extra: PlayerExtra): unknown[] { + const { Box, Text, Button, Input } = els, c = t.colors + // keyed wrap-* so the row-width tests leave it out, as they do the pitch + const line = (text: string, key: string, color: string = c.dim) => wrapped(els, text, color, 'wrap-' + key) + const button = (key: string, label: string, hotkey: string, onPress: () => void) =>