diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index 20a9b4f6..0ca86313 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -160,3 +160,12 @@ {"schema":3,"kind":"decision","id":"d120","text":"The Music card hides shuffle and repeat buttons on the busctl and playerctl backends","state":"adopted","ts":"2026-10-12T11:15:24+00:00","author":"claude-code","session":"session_01BEQogNE1Zk8GftfwcMBNAN","branch":"feat/music-local-player","scope":["hooks/music/reader.ts","hooks/pane.tsx"],"rationale":"A button that does nothing reads as broken; A3's Web API controls both","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"MusicReader.caps() reports no repeat modes and shuffle false on busctl/playerctl; macOS keeps both; state still read","alternatives":["Keep the buttons and let the next read show Spotify ignored the write"],"decided_by":""} {"schema":3,"kind":"review","id":"d115.r1","reviews":"d115","grounds":{"c18":"c20"},"note":"","ts":"2026-10-12T11:15:29+00:00","author":"claude-code","session":"session_01BEQogNE1Zk8GftfwcMBNAN","branch":"feat/music-local-player"} {"schema":3,"kind":"claim","id":"c21","text":"Claude Code 2.1.293 draws both lines of a status line command's two-line output","state":"accepted","ts":"2026-10-12T08:38:09+00:00","author":"claude-code","session":"session_01BEQogNE1Zk8GftfwcMBNAN","branch":"feat/two-line-status","scope":["hooks/statusline.ts"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"tmux probe with --settings printf two lines, 2026-10-12"}],"revisit":"","cost_if_wrong":"","pinned":false} +{"schema":3,"kind":"claim","id":"c22","text":"On Claude Code 2.1.293, $.ui.open({ focus }) for a second pane is refused while the glowup pane holds the keys (it opens as a hidden tab), and granted after $.ui.close of the glowup pane or from a slash command at an empty prompt","state":"accepted","ts":"2026-10-12T11:52:06+00:00","author":"claude-code","session":"session_01BEQogNE1Zk8GftfwcMBNAN","branch":"feat/music-dialog","scope":["hooks/register.tsx"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"tmux probe on the owner's machine, 2026-10-12"}],"revisit":"","cost_if_wrong":"","pinned":false} +{"schema":3,"kind":"claim","id":"c23","text":"A docked pane opened with columns: 110 takes the dock's existing width (88 on 150 and 220 column terminals); inline, rows: 24 is capped by the layout to 13 body rows on a 48-row terminal and 20 on 70","state":"accepted","ts":"2026-10-12T11:52:06+00:00","author":"claude-code","session":"session_01BEQogNE1Zk8GftfwcMBNAN","branch":"feat/music-dialog","scope":["hooks/musicplayer.tsx"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"tmux probe on the owner's machine, 2026-10-12"}],"revisit":"","cost_if_wrong":"","pinned":false} +{"schema":3,"kind":"correction","id":"c22.1","corrects":"c22","fields":{"rationale":"decides how the Player button and o open the music dialog","cost_if_wrong":"the dialog would open without keys or hidden behind the pane"},"reason":"","ts":"2026-10-12T11:52:17+00:00","author":"claude-code","session":"session_01BEQogNE1Zk8GftfwcMBNAN","branch":"feat/music-dialog"} +{"schema":3,"kind":"correction","id":"c23.1","corrects":"c23","fields":{"rationale":"sets the dialog's layout breakpoints","cost_if_wrong":"the side-by-side layout would never show, or the left column would overflow inline"},"reason":"","ts":"2026-10-12T11:52:17+00:00","author":"claude-code","session":"session_01BEQogNE1Zk8GftfwcMBNAN","branch":"feat/music-dialog"} +{"schema":3,"kind":"decision","id":"d121","text":"How the Player button and o open the music dialog","state":"adopted","ts":"2026-10-12T11:52:23+00:00","author":"claude-code","session":"session_01BEQogNE1Zk8GftfwcMBNAN","branch":"feat/music-dialog","scope":["hooks/register.tsx"],"rationale":"focus is refused while the glowup pane holds the keys","supports":[["c22"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"the dialog opens hidden, or the glowup pane is lost after the dialog closes","pinned":false,"choice":"They close the glowup pane, open the dialog with focus, and the dialog's close reopens the glowup pane with focus only when the dialog closed it; /glowup music leaves the glowup pane alone","alternatives":["open the dialog behind the pane and show a click-for-keys line"],"decided_by":""} +{"schema":3,"kind":"decision","id":"d122","text":"Music dialog layout breakpoints","state":"adopted","ts":"2026-10-12T11:52:23+00:00","author":"claude-code","session":"session_01BEQogNE1Zk8GftfwcMBNAN","branch":"feat/music-dialog","scope":["hooks/musicplayer.tsx"],"rationale":"the dock grants 88 columns and inline grants 13-20 rows on common terminals","supports":[["c23"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"layout changes in musicplayer.tsx","pinned":false,"choice":"Side by side from 80 granted columns; the cover shrinks to 8x4 below 18 body rows","alternatives":["the spec's 90 columns and a fixed 20x10 cover"],"decided_by":""} +{"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":""} diff --git a/CHANGELOG.md b/CHANGELOG.md index da376446..1389e465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [1.1.0] - 2026-10-12 ### Added diff --git a/docs/commands.md b/docs/commands.md index 0e5159e2..7051b709 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -176,6 +176,10 @@ Setting `theme` back to `classic`, `spinner` to `pack` or `statusline` to its de `/glowup pane` toggles the pane, or prints why it cannot open when there is no room. Esc also closes a pane opened this way. Where it opens depends on terminal width; see [Layout](layout.md). +## music + +`/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. + ## motion | Command | What it does | diff --git a/docs/layout.md b/docs/layout.md index b2da6dc6..4dc9b77a 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -112,10 +112,19 @@ 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) and `e: ↻` (repeat, off and on). Both are macOS only: Spotify's Linux client ignores repeat and shuffle-on changes over MPRIS, so the 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 on the Spotify on this machine. +- `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. - 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 ▶`. +### Music player + +`/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. + +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. + The tabs do not respond to selection yet; opening a subagent's tool calls is on the [roadmap](roadmap.md). diff --git a/hooks/command.ts b/hooks/command.ts index 814bc264..33dac6b1 100644 --- a/hooks/command.ts +++ b/hooks/command.ts @@ -43,6 +43,8 @@ export type Ctl = { openConfig(): Promise // opens the config TUI in a terminal, or the pane where there is none; returns what to print openConfigTui(): Promise + // opens the Music dialog; returns what to print + openPlayer(): Promise // a -p run: nobody is attached to any surface headless(): Promise fields(): readonly FieldToken[] @@ -339,6 +341,7 @@ export async function runCommand(host: Host, args: string, ctl: Ctl): Promise', 'switch the whole look'], diff --git a/hooks/music/reader.ts b/hooks/music/reader.ts index 7ec8f53e..0d6b95f6 100644 --- a/hooks/music/reader.ts +++ b/hooks/music/reader.ts @@ -3,7 +3,7 @@ import { LOOP_STATUS, OSA_READ, PLAYERCTL_EXTRAS_FORMAT, PLAYERCTL_FORMAT, osaGu export type Platform = 'linux' | 'mac' | 'windows' export type Backend = 'busctl' | 'playerctl' | 'osascript' -export type MusicCaps = { repeat: Repeat[]; shuffle: boolean; raise: boolean } +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.' const RUN_MS = 2000 @@ -70,9 +70,9 @@ export class MusicReader { } caps(): MusicCaps { - // Spotify's Linux client ignores LoopStatus and Shuffle-on writes over MPRIS, so busctl and playerctl offer neither. + // Spotify's Linux client ignores LoopStatus and Shuffle-on writes over MPRIS, and 1.2.74 ignored a Volume write (seen while paused), so busctl and playerctl offer none of them. const mac = this.backend === 'osascript' - return { repeat: mac ? ['off', 'context'] : [], shuffle: mac, raise: this.backend === 'busctl' || this.backend === 'osascript' } + return { repeat: mac ? ['off', 'context'] : [], shuffle: mac, raise: this.backend === 'busctl' || this.backend === 'osascript', volume: mac } } // MPRIS SetPosition names the track it seeks in; it is the one the last read saw. diff --git a/hooks/musicplayer.tsx b/hooks/musicplayer.tsx new file mode 100644 index 00000000..cb45be3c --- /dev/null +++ b/hooks/musicplayer.tsx @@ -0,0 +1,71 @@ +// The Music dialog's body. Engine-free: register.tsx resolves the elements and builds the Clients. +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 { 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' + +export const PLAYER_ID = 'glowup-music' +// 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 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 function renderPlayer(els: { Box: any; Text: any; Button: any }, 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')) + 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')) + 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 } + const seek: SeekBarProps = { kind: 'progress', cols: lw, progressMs: s.progressMs, readAt: s.readAt, durationMs: track.durationMs, playing: s.playing, colors } + left.push(extra.seek ? {extra.seek(seek) as any} : renderSegs(els, barRow(seek, lw, progressAt(s, now)), 'seek')) + if (v.caps?.volume && s.volume !== undefined) { + const vol: SeekBarProps = { kind: 'volume', cols: lw, level: s.volume, colors } + left.push(extra.volume ? {extra.volume(vol) as any} : renderSegs(els, barRow(vol, lw, s.volume), 'volume')) + } + const buttons = musicControls(s, v.caps).map(b =>