Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .docket/ledger.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,6 @@
{"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-<session>-<seq>, 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":""}
{"schema":3,"kind":"decision","id":"d129","text":"Up next skips to a queued track one skipTo at a time, and refuses a track 20 or more places down","state":"adopted","ts":"2026-10-13T11:37:21+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-lists","scope":["hooks/music/spotify.ts"],"rationale":"a second skipTo during the first one's nexts overshot the target, and a capped run plays an arbitrary track","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"far rows need manual Next presses","pinned":false,"choice":"SpotifyClient.skipping makes a second skipTo answer busy; a target at index 20 or more sends no next and shows 'That track is too far down the queue.'; the queue is re-read on each press","alternatives":["cap the run at 20 nexts and land wherever it stops"],"decided_by":""}
{"schema":3,"kind":"decision","id":"d130","text":"The Devices list opens with no preselected row","state":"adopted","ts":"2026-10-13T11:37:21+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-lists","scope":["hooks/musicplayer.tsx"],"rationale":"if the engine treats a controlled value as a selection, opening Devices would transfer with play: true and resume paused playback, and that cannot be checked without a connection","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"the cursor starts at the top instead of on the active device","pinned":false,"choice":"the Select carries no value; the active device is marked on its row","alternatives":["preselect the active device"],"decided_by":""}
{"schema":3,"kind":"decision","id":"d131","text":"List commands run only through the Web API","state":"adopted","ts":"2026-10-13T11:37:21+00:00","author":"claude-code","session":"session_01UpYey2RhLeNfBnAiprrv4G","branch":"feat/music-lists","scope":["hooks/register.tsx"],"rationale":"the local app has no equivalent for them","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"list rows do nothing without Premium","pinned":false,"choice":"play, playContext, transfer and skipTo never fall back to the local app; a failure shows inline, and PREMIUM_REQUIRED shows 'Spotify needs Premium for this.'","alternatives":["fall back to the local app like transport"],"decided_by":""}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- `/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.
- With Spotify connected, the music player lists Up next, Recent, Playlists and Devices (`1` to `4`). Enter skips forward to a queued track, replays a recent one in its playlist or album, starts a playlist, or moves playback to another device.

## [1.1.0] - 2026-10-12

Expand Down
2 changes: 1 addition & 1 deletion docs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,6 @@ The track playing in the Spotify desktop app, in a `NOW PLAYING` box. The tab st

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; 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.
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 holds four lists, Up next, Recent, Playlists and Devices, on `1` to `4`; see [the lists](spotify.md#the-lists). 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).
27 changes: 24 additions & 3 deletions docs/spotify.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: Connect your own Spotify developer app so the Music tab and player
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
- four lists in the player: Up next, Recent, Playlists and Devices
- 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
Expand All @@ -23,7 +23,7 @@ Without connecting, glowup reads the Spotify app on this computer. Connecting yo
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.
6. Your browser opens Spotify's consent page. Agree, and the player shows your lists.

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.

Expand All @@ -37,6 +37,27 @@ While glowup waits, `w` copies the consent page's address in case the browser di

`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.

## The lists

Once connected, the player's right column has four lists. Press `1` to `4` to switch, Tab to reach the list, the arrows to move, and Enter to act:

| Key | List | Enter |
|---|---|---|
| `1` | Up next: the next 20 tracks in your queue | Skips forward to that track |
| `2` | Recent: the last 20 tracks you played | Plays it again, inside the playlist or album it played from |
| `3` | Playlists: all of yours | Plays the playlist |
| `4` | Devices: every Spotify device you are signed in on, the active one marked | Moves playback there and plays |

Recent plays a track alone when it came from anything but an album or a playlist, such as an artist page or Liked Songs.

When the same track is queued twice, Up next skips to its first copy.

Up next refreshes when the track changes and every 15 seconds while it is on screen, Recent when the track changes, Devices every 15 seconds and each time you open the list, and Playlists once each time the player opens. If a refresh fails, the rows stay and a line under them says why; glowup tries again after 15 seconds.

Spotify cannot jump within the queue, so skipping to the fourth track skips four times. If the track has already left the queue, the player says so and reloads the list; a track more than 20 down the queue gets "That track is too far down the queue." Spotify also cannot remove or reorder queued tracks. If a queue read is still out when you press Enter, the player says "Still reading the queue, try again." and skips nothing.

When nothing is playing on any device, the player opens on Devices: pick one to start there.

## 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.
Expand All @@ -51,7 +72,7 @@ glowup keeps the login in `~/.claude/glowup/spotify.json` (under `$CLAUDE_CONFIG

- **`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.
- **"Nothing is playing. Pick a device."** No device has Spotify active. The player switches to Devices; pick one, or 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`.
Expand Down
102 changes: 102 additions & 0 deletions hooks/music/lists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// The Music dialog's four lists: what each holds, when it reloads, and what Enter on a row sends.
// JSX-free and engine-free, so the rules test on plain data.
import type { Music, MusicCmd, QueueItem } from './parse.ts'
import type { ApiError, DeviceItem, PlaylistItem, RecentItem } from './webapi.ts'
import type { SkipResult } from './spotify.ts'
import { mmss } from './seek.ts'

export type ListId = 'queue' | 'recent' | 'playlists' | 'devices'
export const LISTS: readonly (readonly [ListId, string])[] = [['queue', 'Up next'], ['recent', 'Recent'], ['playlists', 'Playlists'], ['devices', 'Devices']]
// value is the Select option's value, unique within its list; cmd is what Enter on the row sends
export type ListRow = { value: string; title: string; detail: string; right: string; cmd: MusicCmd; active?: boolean }
// Rows survive a reload and a failed one, so the 15 s refresh never blanks a list.
// forUri is the track playing when the list loaded: Up next and Recent reload when it changes.
export type ListState =
| { status: 'idle' }
| { status: 'loading'; rows: ListRow[] }
| { status: 'ready'; rows: ListRow[]; at: number; forUri?: string }
| { status: 'error'; rows: ListRow[]; reason: string; at: number; forUri?: string }
export type Lists = { shown: ListId; byId: Record<ListId, ListState> }

export const LIST_MAX = 20
export const RELOAD_MS = 15_000
export const EMPTY: Record<ListId, string> = { queue: 'Nothing queued.', recent: 'Nothing played yet.', playlists: 'No playlists yet.', devices: 'No devices. Open Spotify on one.' }
const IDLE: ListState = { status: 'idle' }

// With nothing active anywhere (a 204) the dialog opens on Devices, the one list that can start playback somewhere.
export function initialLists(m?: Music): Lists {
const none = m?.status === 'open' && m.source === 'api' && !m.device
return { shown: none ? 'devices' : 'queue', byId: { queue: IDLE, recent: IDLE, playlists: IDLE, devices: IDLE } }
}

// Asked only for the list on screen. Closing the dialog resets every list, so "once per open" is "once".
export function needsLoad(id: ListId, s: ListState, now: number, trackUri: string | undefined): boolean {
if (s.status === 'idle') return true
if (s.status === 'loading') return false
const aged = now - s.at >= RELOAD_MS
if (s.status === 'error') return aged
switch (id) {
case 'queue': return s.forUri !== trackUri || aged
case 'recent': return s.forUri !== trackUri
case 'playlists': return false
case 'devices': return aged
}
}

export const rowsOf = (s: ListState): ListRow[] => (s.status === 'idle' ? [] : s.rows)
export const loadingFrom = (s: ListState): ListState => ({ status: 'loading', rows: rowsOf(s) })
// Due at the next check: Devices each time they are shown and after a transfer, Up next after a skip.
// -Infinity, not 0: the engine's clock in tests starts at 0, where at: 0 would not be 15 s old.
export function expire(l: Lists, id: ListId): Lists {
const s = l.byId[id]
return s.status === 'ready' || s.status === 'error' ? { ...l, byId: { ...l.byId, [id]: { ...s, at: -Infinity } } } : l
}

const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
// Spotify takes an offset into albums and playlists only; an artist, show or Liked Songs context plays the track alone.
const offsetable = (uri: string) => /^spotify:(album|playlist):/.test(uri)

// The same track can be queued or played twice, so the index keeps each value unique; commands still go by URI.
export const queueRows = (q: QueueItem[]): ListRow[] => q.slice(0, LIST_MAX).map((t, i) => ({ value: `${i}:${t.uri}`, title: t.title, detail: t.artist, right: mmss(t.durationMs), cmd: { cmd: 'skipTo', uri: t.uri } }))

export const recentRows = (r: RecentItem[]): ListRow[] => r.slice(0, LIST_MAX).map((t, i) => ({
value: `${i}:${t.uri}`, title: t.title, detail: t.artist, right: mmss(t.durationMs),
cmd: t.contextUri && offsetable(t.contextUri) ? { cmd: 'playContext', uri: t.contextUri, offset: t.uri } : { cmd: 'play', uri: t.uri },
}))

export const playlistRows = (p: PlaylistItem[]): ListRow[] => p
.filter((x, i) => p.findIndex(y => y.uri === x.uri) === i)
.map(x => ({ value: x.uri, title: x.name, detail: x.owner, right: plural(x.tracks, 'track'), cmd: { cmd: 'playContext', uri: x.uri } }))

export const deviceRows = (d: DeviceItem[]): ListRow[] => d.map(x => ({ value: x.id, title: x.name, detail: x.type, right: x.active ? 'active' : '', cmd: { cmd: 'transfer', deviceId: x.id }, ...(x.active && { active: true }) }))

export function listError(e: ApiError): string {
switch (e.kind) {
case 'server': return `Spotify had a problem (${e.status}). glowup tries again shortly.`
// reason is Spotify's own text; only a bare code like NO_ACTIVE_DEVICE is shown
case 'refused': return `Spotify refused the list (${/^[A-Z_]{1,40}$/.test(e.reason) ? e.reason : e.status}).`
case 'rate': return 'Spotify asked glowup to slow down. glowup tries again shortly.'
case 'reconnect': return 'Spotify needs you to connect again. Run /glowup spotify connect.'
case 'noDevice': return 'No device is playing. Pick one in the Devices list.'
case 'premium': return 'Spotify needs Premium for this list.'
default: return 'Spotify did not answer. glowup tries again shortly.'
}
}

// A pressed row is not retried, unlike a list load (15 s), so the transient failures ask the user to press again.
export function pressError(e: ApiError): string {
switch (e.kind) {
case 'server': case 'rate': case 'busy': case 'offline': return 'Spotify is not answering right now. Try again.'
default: return listError(e)
}
}

export function skipText(r: SkipResult): string {
switch (r.kind) {
case 'done': return ''
case 'gone': return 'That track already left the queue.'
case 'far': return 'That track is too far down the queue.'
case 'busy': return 'Still reading the queue, try again.'
case 'error': return pressError(r.error)
}
}
5 changes: 5 additions & 0 deletions hooks/music/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export type MusicCmd =
| { cmd: 'repeat'; mode: Repeat }
| { cmd: 'volume'; level: number }
| { cmd: 'like'; on: boolean }
| { cmd: 'skipTo'; uri: string }
| { cmd: 'play'; uri: string }
// offset starts the context at that track, so what follows it is what followed it when it last played
| { cmd: 'playContext'; uri: string; offset?: string }
| { cmd: 'transfer'; deviceId: string }
export type Extras = Pick<OpenMusic, 'shuffle' | 'repeat' | 'volume'>

const isAd = (id: string) => id.startsWith('/com/spotify/ad/') || id.startsWith('spotify:ad:')
Expand Down
3 changes: 3 additions & 0 deletions hooks/music/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ function keepProgress(m: Music, prev: Music | undefined, now: number): Music {
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'
// The lists' commands are API-only. They run with nothing active too: Spotify's NO_ACTIVE_DEVICE answer is what
// sends the person to Devices, and transfer is the way out of that state.
if (cmd.cmd === 'skipTo' || cmd.cmd === 'play' || cmd.cmd === 'playContext' || cmd.cmd === 'transfer') return m?.status === 'open' && m.source === 'api' ? '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'
Expand Down
Loading
Loading