diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index b2613ec..f29d233 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -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--, 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":""} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1188dfa..738b934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/layout.md b/docs/layout.md index 11f47ae..cdab999 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -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). diff --git a/docs/spotify.md b/docs/spotify.md index 6139413..90ac490 100644 --- a/docs/spotify.md +++ b/docs/spotify.md @@ -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 @@ -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. @@ -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. @@ -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`. diff --git a/hooks/music/lists.ts b/hooks/music/lists.ts new file mode 100644 index 0000000..0fe646d --- /dev/null +++ b/hooks/music/lists.ts @@ -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 } + +export const LIST_MAX = 20 +export const RELOAD_MS = 15_000 +export const EMPTY: Record = { 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) + } +} diff --git a/hooks/music/parse.ts b/hooks/music/parse.ts index 9cf30ce..b8bb00f 100644 --- a/hooks/music/parse.ts +++ b/hooks/music/parse.ts @@ -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 const isAd = (id: string) => id.startsWith('/com/spotify/ad/') || id.startsWith('spotify:ad:') diff --git a/hooks/music/player.ts b/hooks/music/player.ts index 7c3d9ea..96c4465 100644 --- a/hooks/music/player.ts +++ b/hooks/music/player.ts @@ -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' diff --git a/hooks/music/spotify.ts b/hooks/music/spotify.ts index c63b6f6..ffb3ace 100644 --- a/hooks/music/spotify.ts +++ b/hooks/music/spotify.ts @@ -2,7 +2,7 @@ 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' +import { apiError, commandRequest, parsePlayer, parsePlaylists, parseQueue, playerRequest, playlistsRequest, queueRequest, type ApiError, type ApiRequest, type PlaylistItem } from './webapi.ts' export const API_TIMEOUT_MS = 5000 export type ApiAnswer = { ok: true; status: number; text: string; at: number } | { ok: false; error: ApiError } @@ -12,6 +12,8 @@ export class SpotifyClient { private until = 0 // false once a write answered PREMIUM_REQUIRED: transport then goes through the local app for the session premium = true + // set for the whole of a skipTo: a second one would read a queue the first is still consuming + skipping = false constructor(private host: Host, private keeper: TokenKeeper, private now: () => number) {} async send(r: ApiRequest): Promise { @@ -45,7 +47,8 @@ export class SpotifyClient { } 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) + const headers: Record = { Authorization: `Bearer ${token}`, ...(r.body !== undefined && { 'Content-Type': 'application/json' }) } + return this.host.fetch(r.url, { method: r.method, headers, ...(r.body !== undefined && { body: r.body }), timeoutMs: API_TIMEOUT_MS, onSettled }).catch(() => undefined) } } @@ -58,3 +61,48 @@ export async function readPlayer(c: SpotifyClient): Promise { const music = parsePlayer(a.text, a.at) return music ? { ok: true, at: a.at, music } : { ok: false, error: { kind: 'server', status: a.status } } } + +export const SKIP_MAX = 20 +// 20 pages of 50: past a thousand playlists the list stops rather than polling on +export const PLAYLIST_PAGES = 20 + +export async function allPlaylists(c: SpotifyClient): Promise<{ ok: true; items: PlaylistItem[] } | { ok: false; error: ApiError }> { + const items: PlaylistItem[] = [] + let next: string | undefined + for (let page = 0; page < PLAYLIST_PAGES; page++) { + const a = await c.send(playlistsRequest(next)) + if (!a.ok) return a + const p = parsePlaylists(a.text) + if (!p) return { ok: false, error: { kind: 'server', status: a.status } } + items.push(...p.items) + if (!p.next) break + next = p.next + } + return { ok: true, items } +} + +// busy: another skipTo or queue read was still out, so nothing was skipped; far: the target is SKIP_MAX or more down +export type SkipResult = { kind: 'done' } | { kind: 'gone' } | { kind: 'far' } | { kind: 'busy' } | { kind: 'error'; error: ApiError } +// The Web API cannot jump within the queue, only skip: so skipTo carries the target's URI, not an index, +// re-reads the queue (it may have moved since the list was drawn) and sends next until the target plays. +export async function skipTo(c: SpotifyClient, uri: string): Promise { + if (c.skipping) return { kind: 'busy' } + c.skipping = true + try { + const q = await c.send(queueRequest()) + if (!q.ok) return q.error.kind === 'busy' ? { kind: 'busy' } : { kind: 'error', error: q.error } + const queue = parseQueue(q.text) + if (!queue) return { kind: 'error', error: { kind: 'server', status: q.status } } + const at = queue.findIndex(t => t.uri === uri) + if (at < 0) return { kind: 'gone' } + // a capped run would stop on some other track + if (at >= SKIP_MAX) return { kind: 'far' } + const next = commandRequest({ cmd: 'next' }, true)! + for (let i = 0; i <= at; i++) { + // awaited one by one: Spotify applies them in order, and a refusal (an ad, a restriction) stops the rest + const r = await c.send(next) + if (!r.ok) return { kind: 'error', error: r.error } + } + return { kind: 'done' } + } finally { c.skipping = false } +} diff --git a/hooks/music/webapi.ts b/hooks/music/webapi.ts index d8073d0..8ab6e2e 100644 --- a/hooks/music/webapi.ts +++ b/hooks/music/webapi.ts @@ -2,7 +2,8 @@ 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 } +// body is JSON; SpotifyClient sends it with its content type +export type ApiRequest = { method: 'GET' | 'PUT' | 'POST' | 'DELETE'; url: string; body?: 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` }) @@ -11,10 +12,14 @@ 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)}` }) +export const recentRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me/player/recently-played?limit=20` }) +// next is the URL a page names for the one after it, already checked by parsePlaylists +export const playlistsRequest = (next?: string): ApiRequest => ({ method: 'GET', url: next ?? `${API}/me/playlists?limit=50` }) +export const devicesRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me/player/devices` }) 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. +// The write a transport command makes; playing picks pause or play. like goes through likeRequest, skipTo through spotify.ts's skipTo, 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'}` } @@ -24,6 +29,10 @@ export function commandRequest(c: MusicCmd, playing: boolean): ApiRequest | unde 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)}` } + case 'play': return { method: 'PUT', url: `${API}/me/player/play`, body: JSON.stringify({ uris: [c.uri] }) } + case 'playContext': return { method: 'PUT', url: `${API}/me/player/play`, body: JSON.stringify({ context_uri: c.uri, ...(c.offset && { offset: { uri: c.offset } }) }) } + // play: true starts it there; without it Spotify moves playback paused + case 'transfer': return { method: 'PUT', url: `${API}/me/player`, body: JSON.stringify({ device_ids: [c.deviceId], play: true }) } default: return undefined } } @@ -91,6 +100,46 @@ export function parseMe(text: string): string | undefined { return str(o?.display_name) || str(o?.id) || undefined } +export type RecentItem = QueueItem & { contextUri?: string } +export function parseRecent(text: string): RecentItem[] | undefined { + const items = obj(json(text))?.items + if (!Array.isArray(items)) return undefined + return items.flatMap((v): RecentItem[] => { + const o = obj(v), t = trackOf(o?.track) + // a local file has no Web API representation: play and the queue cannot address it + if (!t || t.uri!.startsWith('spotify:local:')) return [] + const ctx = str(obj(o?.context)?.uri) + return [{ uri: t.uri!, title: t.title, artist: t.artist, durationMs: t.durationMs, ...(ctx && { contextUri: ctx }) }] + }) +} + +export type PlaylistItem = { uri: string; name: string; owner: string; tracks: number } +// next is kept only on Spotify's own API host over https: the bearer token goes wherever it points. +export function parsePlaylists(text: string): { items: PlaylistItem[]; next?: string } | undefined { + const o = obj(json(text)) + if (!o || !Array.isArray(o.items)) return undefined + const items = o.items.flatMap((v): PlaylistItem[] => { + const p = obj(v), uri = str(p?.uri) + if (!p || !uri) return [] + const owner = obj(p.owner) + // Spotify renamed the playlist's tracks field to items in 2026; either may come back + return [{ uri, name: str(p.name), owner: str(owner?.display_name) || str(owner?.id), tracks: num(obj(p.items)?.total ?? obj(p.tracks)?.total) }] + }) + const next = str(o.next) + return { items, ...(next.startsWith(`${API}/`) && { next }) } +} + +export type DeviceItem = { id: string; name: string; type: string; active: boolean } +// A restricted device has no id and cannot take playback, so it is left out. +export function parseDevices(text: string): DeviceItem[] | undefined { + const d = obj(json(text))?.devices + if (!Array.isArray(d)) return undefined + return d.flatMap((v): DeviceItem[] => { + const o = obj(v), id = str(o?.id) + return o && id ? [{ id, name: str(o.name), type: str(o.type), active: o.is_active === true }] : [] + }) +} + export type ApiError = // a network error, or no answer within the 5 s timeout | { kind: 'offline' } @@ -102,7 +151,7 @@ export type ApiError = | { kind: 'reconnect' } | { kind: 'premium' } | { kind: 'noDevice' } - | { kind: 'refused'; reason: string } + | { kind: 'refused'; reason: string; status: number } | { kind: 'server'; status: number } // Spotify's error.reason tells PREMIUM_REQUIRED and NO_ACTIVE_DEVICE from other 403s and 404s. @@ -116,5 +165,5 @@ export function apiError(status: number, text: string, headers: Record 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 + onList?: (id: ListId) => void; onPick?: (value: string) => void } // State, note and account lines wrap, since a 30-column left column truncates "Open Spotify to see what's playing." @@ -79,7 +81,7 @@ export function renderPlayer(els: Els, m: Model, t: Theme, v: PlayerView, now: n const rw = side ? Math.max(1, v.width - LEFT_COLS - GAP) : v.width const right = ( - {accountRows(els, t, v.account ?? { step: 'pitch' }, s, extra) as any} + {accountRows(els, t, v.account ?? { step: 'pitch' }, s, extra, rw) as any} ) return ( @@ -90,7 +92,7 @@ export function renderPlayer(els: Els, m: Model, t: Theme, v: PlayerView, now: n ) } -function accountRows(els: Els, t: Theme, a: AccountView, s: Music, extra: PlayerExtra): unknown[] { +function accountRows(els: Els, t: Theme, a: AccountView, s: Music, extra: PlayerExtra, w: number): 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) @@ -127,10 +129,12 @@ function accountRows(els: Els, t: Theme, a: AccountView, s: Music, extra: Player } case 'connected': { const o = s.status === 'open' ? s : undefined + const device = o?.device ? [line(`on: ${o.device.name}${o.device.isThisComputer ? ' (this computer)' : ''}`, 'device')] : [] + if (a.lists) return [...listRows(els, t, a.lists, w, extra), ...device] return [ line(a.name ? `Connected as ${a.name}.` : 'Connected.', 'name', c.text), ...(o?.upNext ? [line(upNextLine(o.upNext), 'next')] : []), - ...(o?.device ? [line(`on: ${o.device.name}${o.device.isThisComputer ? ' (this computer)' : ''}`, 'device')] : []), + ...device, line('/glowup spotify disconnect stops it.', 'disconnect'), ] } @@ -140,3 +144,44 @@ function accountRows(els: Els, t: Theme, a: AccountView, s: Music, extra: Player return [line(a.text, 'refused', c.fail)] } } + +// One row as a fixed-width label, since a Select option is one string: the title gives way first, +// the detail takes at most 40%, and the right column (a length, a count, "active") is dropped only when nothing else fits. +export function listLabel(r: ListRow, w: number): string { + const len = (s: string) => visibleLength([{ text: s, color: '' }]) + const pad = (s: string, n: number) => { + const f = fit([{ text: s, color: '' }], n) + return f.map(x => x.text).join('') + ' '.repeat(Math.max(0, n - visibleLength(f))) + } + const right = r.right && w >= len(r.right) + 8 ? ' ' + r.right : '' + const room = w - len(right) + const detailW = r.detail ? Math.min(len(r.detail), Math.floor(room * 0.4)) : 0 + const titleW = room - (detailW > 0 ? detailW + 2 : 0) + return pad(r.title, titleW) + (detailW > 0 ? ' ' + pad(r.detail, detailW) : '') + right +} + +function listRows(els: Els, t: Theme, v: { shown: ListId; state: ListState }, w: number, extra: PlayerExtra): unknown[] { + const { Box, Button, Select } = els, c = t.colors + // Loading…, empty and error lines wrap like the state and note lines, keyed wrap-* so the row-width tests skip them; + // only the Select-less rows are fixed-width labels + const say = (text: string, key: string) => wrapped(els, text, c.dim, 'wrap-' + key) + const tabs = ( + + {LISTS.map(([id, label], i) =>