From b565b7046bfe7af0245d4349fc0542fd5ad4d005 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 02:04:57 +0300 Subject: [PATCH 1/9] feat(pane): music player dialog layout Signed-off-by: NovusEdge --- .docket/ledger.jsonl | 7 ++ hooks/music/reader.ts | 6 +- hooks/musicplayer.tsx | 69 ++++++++++++++++++++ hooks/pane.tsx | 2 +- test/music-reader.test.ts | 6 +- test/musicplayer.test.ts | 132 ++++++++++++++++++++++++++++++++++++++ test/pane.test.ts | 6 +- 7 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 hooks/musicplayer.tsx create mode 100644 test/musicplayer.test.ts diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index 20a9b4f6..4a9b55df 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -160,3 +160,10 @@ {"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} diff --git a/hooks/music/reader.ts b/hooks/music/reader.ts index 7ec8f53e..e31d3599 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, Shuffle-on and Volume writes over MPRIS, 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..bcb5a955 --- /dev/null +++ b/hooks/musicplayer.tsx @@ -0,0 +1,69 @@ +// 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 (ledger c23), so these fit the grants seen, not the 110x24 asked for. +const LEFT_COLS = 30, SIDE_MIN = 80, GAP = 2 +export const playerCoverSize = (bodyRows: number): 0 | 8 | 20 => (bodyRows >= 18 ? 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 =>