Plays a YouTube Music or Spotify playlist while Claude works, pauses whenever any session needs your input, in a Winamp-style pane shared by every session on…

A Claude Code mod that plays a YouTube Music or Spotify playlist while Claude works, and pauses it whenever you are needed. The player sits in a Winamp-style pane: the app, the track, the playlist, the elapsed time, a spectrum, and previous, play, pause and next controls.
At the prompt of a terminal session:
/plugin install coding-tunes --marketplace ericfisherdev/ericfisherdev-plugins
Answer y to add the marketplace and pick a scope. Requires Claude Code 2.1.296 or later (function hooks), Linux and Python 3.
| Command | What it does |
|---|---|
/coding-tunes --player=ytmusic --list=<playlist id> | Loads the playlist (--player=spotify for Spotify). An id or a link both work. |
/coding-tunes | Asks for the player and the playlist. |
/coding-tunes --setup | Saves the default player and playlist. For YouTube Music it offers a browser login, for Spotify it links your account. |
/coding-tunes --play / --pause / --next / --prev | The pane's buttons as commands. They run at once, even while Claude is working. |
/coding-tunes --stop | Stops the shared player and closes the pane. |
While Claude is working, a command that would ask a question (bare /coding-tunes, --setup, or a start missing its player or playlist) is refused; run it once Claude is idle.
z previous, x play, c pause, b next. Clicks on the buttons reach the pane only in the fullscreen layout; on the main screen use the hotkeys or the --play, --pause, --next and --prev commands.Needs mpv and yt-dlp from your package manager (for example sudo pacman -S mpv yt-dlp on Arch, sudo apt install mpv yt-dlp on Debian or Ubuntu). One detached mpv plays for every session and quits when the last session ends. If the last session crashes instead of ending, mpv keeps playing until the next session starts and pauses it, or until /coding-tunes --stop.
Private playlists need a browser's YouTube login: run /coding-tunes --setup, choose YouTube Music, and pick the browser you are signed in with. Firefox-family browsers (Zen, Firefox, LibreWolf, Floorp) are found by their default profile; Chromium-family ones (Chrome, Brave, Chromium, Vivaldi, Edge, Opera) may ask you to unlock your keyring. Without a browser login only public playlists open, and a playlist YouTube refuses shows an error in the pane.
Streaming YouTube through yt-dlp may be against YouTube's Terms of Service.
Needs Spotify Premium (the Web API's playback controls require it), and a Spotify client open somewhere for the sound to come from.
http://127.0.0.1:8898/callback./coding-tunes --setup, choose Spotify, and paste the app's client id under "Other".The pane docks at the top right, beside the transcript, in Claude Code's fullscreen layout at 110 columns or more. On the main-screen layout (CLAUDE_CODE_NO_FLICKER=0, and tmux by default) or a narrower terminal it sits above the prompt; /coding-tunes says which applies.
$XDG_RUNTIME_DIR/coding-tunes/ (or ~/.cache/coding-tunes/ when XDG_RUNTIME_DIR is unset; never /tmp): one small record per session, the shared intent, and mpv's socket and lock. Under $XDG_RUNTIME_DIR it lives in memory and is gone after a reboot.claude plugin validate <folder>
claude plugin test <folder>
The tests in tests/ drive the rules directly and the hooks through the engine, with a fake mpv, file system and clock.
hooks/register.tsx 229 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import type { TunesSnapshot } from '../types'
4import { WORK_THRESHOLD_MS } from './coordination'
5import type { HostPorts } from './ports'
6import { buildServices, resolveRuntimeDir } from './services'
7import type { TunesServices } from './services'
8import type { ControlOutcome } from './tunes-controls'
9import { WinampView } from './winamp-view'
10
11const SNAPSHOT = { plugin: 'coding-tunes', key: 'snapshot' } as const
12const COMMAND = 'coding-tunes'
13const PANE = 'coding-tunes'
14const PANE_COLUMNS = 46
15const PANE_ROWS = 9
16const TICK_MS = 3000
17/** The fullscreen layout docks a pane beside the transcript from this width. */
18const DOCK_MIN_COLUMNS = 110
19
20/** Why the pane sits above the prompt instead of docked top-right, if it does. */
21const placementHint = (presentation: { isFullscreen: boolean; columns: number }): string | undefined => {
22 if (!presentation.isFullscreen) {
23 return 'The player sits above the prompt because this terminal uses the main-screen layout. It docks at the top right in the fullscreen layout, which CLAUDE_CODE_NO_FLICKER=0 and tmux turn off by default.'
24 }
25 if (presentation.columns < DOCK_MIN_COLUMNS) {
26 return `The player sits above the prompt because the terminal is ${presentation.columns} columns wide; it docks at the top right from ${DOCK_MIN_COLUMNS}.`
27 }
28
29 return undefined
30}
31
32/** The engine interface as the plain ports the services take. */
33const hostPortsFrom = async ($: EngineInterface): Promise<HostPorts> => {
34 const homeDir = (await $.env.get('HOME')) ?? ''
35 const runtimeDir = resolveRuntimeDir(await $.env.get('XDG_RUNTIME_DIR'), homeDir, $.plugin.root)
36
37 return {
38 runner: {
39 run: (argv, timeoutMs) => $.process.run(argv, timeoutMs === undefined ? undefined : { timeoutMs }),
40 },
41 http: {
42 request: async (url, init) => {
43 const reply = await $.http.fetch(url, init)
44
45 return { status: reply.status, ok: reply.ok, text: reply.text }
46 },
47 },
48 files: {
49 read: path => $.fs.read(path).then(text => text, () => undefined),
50 write: (path, text) => $.fs.write(path, text),
51 list: dir => $.fs.list(dir).then(entries => entries.map(entry => entry.name), () => []),
52 remove: async path => {
53 await $.process.run(['rm', '-f', '--', path])
54 },
55 },
56 localFiles: {
57 read: path => $.fs.read(path).then(text => text, () => undefined),
58 exists: path => $.fs.exists(path),
59 },
60 store: {
61 get: key => $.store.get(key),
62 set: (key, value) => $.store.set(key, value),
63 },
64 clock: { now: () => $.clock.now() },
65 snapshots: {
66 show: async snapshot => {
67 await $.state.set(SNAPSHOT, snapshot)
68 },
69 },
70 notifier: {
71 notify: text => $.ui.toast(text),
72 log: text => $.ui.log(text),
73 },
74 prompter: { ask: (question, options) => $.ui.ask(question, options) },
75 pluginRoot: $.plugin.root,
76 runtimeDir,
77 homeDir,
78 sessionId: await $.session.id(),
79 }
80}
81
82const EMPTY_SNAPSHOT: TunesSnapshot = {
83 player: null,
84 playlistId: null,
85 track: '',
86 isPlaying: false,
87 elapsedSeconds: null,
88 heldBy: null,
89 isHeldHere: false,
90 notice: null,
91 frame: 0,
92}
93
94const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Coding Tunes', columns: PANE_COLUMNS, rows: PANE_ROWS })
95
96export const register: Register = on => {
97 let services: TunesServices | undefined
98 let ticker: Timer | undefined
99
100 on('session.start', async ($, e, next) => {
101 const started = await next(e)
102 const built = buildServices(await hostPortsFrom($), e.cwd)
103 services = built
104 await $.command.register({
105 name: COMMAND,
106 description: 'Play a YouTube Music or Spotify playlist while Claude works',
107 argumentHint: '[--player=ytmusic|spotify] [--list=<id>] | --play | --pause | --next | --prev | --setup | --stop',
108 immediate: true,
109 })
110 ticker = $.clock.every(TICK_MS, () => void built.sync.reconcile())
111 await built.sync.reconcile()
112 const intent = await built.board.readIntent()
113 if (e.isInteractive && intent?.isEnabled === true) void openPane($)
114
115 return started
116 })
117
118 // A /clear ends the conversation but not the process, and no session.start follows: keep playing.
119 on('session.end', async ($, e, next) => {
120 if (e.reason !== 'clear') {
121 ticker?.cancel()
122 await services?.sync.end().catch(() => undefined)
123 }
124
125 return next(e)
126 })
127
128 on('turn.start', async ($, e, next) => {
129 if (services !== undefined) {
130 const { activity, controls, sync } = services
131 activity.beginTurn(await $.clock.now())
132 await controls.clearOverride('paused')
133 $.clock.after(WORK_THRESHOLD_MS + 250, () => void sync.reconcile())
134 void sync.reconcile()
135 }
136
137 return next(e)
138 })
139
140 on('turn.complete', async ($, e, next) => {
141 if (services !== undefined && e.agentId === undefined) {
142 services.activity.endTurn()
143 await services.controls.clearOverride('playing')
144 await services.sync.reconcile()
145 }
146
147 return next(e)
148 })
149
150 // A question to the person holds the player paused until it is answered.
151 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
152 const active = services
153 if (active === undefined) return next(e)
154 active.activity.openQuestion(e.tool_use_id)
155 await active.sync.reconcile()
156 try {
157 return await next(e)
158 } finally {
159 active.activity.closeQuestion(e.tool_use_id)
160 void active.sync.reconcile()
161 }
162 }).catch(($, e, next) => next(e))
163
164 // Remember which calls the engine put to the person's decider.
165 on('tool.check', async ($, e, next) => {
166 const verdict = await next(e)
167 if (verdict.decision === 'ask' && e.tool_use_id !== undefined) services?.activity.markAsked(e.tool_use_id)
168
169 return verdict
170 }).catch(($, e, next) => next(e))
171
172 // The permission dialog is about to show: pause before it does.
173 on('classic.PermissionRequest', async ($, e, next) => {
174 if (services !== undefined) {
175 services.activity.openPermissionPrompt()
176 await services.sync.reconcile()
177 }
178
179 return next(e)
180 }).catch(($, e, next) => next(e))
181
182 // A call that went through the dialog has been answered once it settles.
183 on('tool.call', async ($, e, next) => {
184 const result = await next(e)
185 if (services !== undefined) {
186 const before = services.activity.holdReason()
187 services.activity.settleToolCall(e.tool_use_id)
188 if (services.activity.holdReason() !== before) void services.sync.reconcile()
189 }
190
191 return result
192 }).catch(($, e, next) => next(e))
193
194 on('command.run', { command: COMMAND }, async ($, e) => {
195 if (services === undefined) return { text: 'Coding Tunes is still starting; try again in a moment.' }
196 const text = await services.flow.run(e.args)
197 const intent = await services.board.readIntent()
198 if (intent?.isEnabled !== true) {
199 await $.ui.close({ id: PANE })
200 return { text }
201 }
202 await openPane($)
203 const hint = placementHint(e.presentation)
204
205 return { text: hint === undefined ? text : `${text}\n${hint}` }
206 }).catch(() => ({ text: 'Coding Tunes hit an error; see claude --debug.' }))
207
208 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
209 const { value } = await $.state.get(SNAPSHOT)
210 const active = services
211 const act = (control: (current: TunesServices) => Promise<ControlOutcome>) => () => {
212 if (active === undefined) return
213 void control(active).then(
214 outcome => {
215 if (!outcome.isDone) $.ui.toast(outcome.reason)
216 },
217 (error: unknown) => $.ui.toast(error instanceof Error ? error.message : String(error)),
218 )
219 }
220
221 return WinampView($.ui.resolve(e), value ?? EMPTY_SNAPSHOT, e.props.bodyColumns, {
222 previous: act(current => current.controls.previous()),
223 play: act(current => current.controls.play()),
224 pause: act(current => current.controls.pause()),
225 next: act(current => current.controls.next()),
226 })
227 })
228}
229hooks/coordination.ts 140 lines1import type { PlayerKind } from '../types'
2import type { SharedFiles } from './ports'
3
4/** Why a session needs the person's input. */
5export type HoldReason = 'permission' | 'question'
6
7/** What one session publishes about itself to every other session. */
8export type SessionRecord = {
9 sessionId: string
10 label: string
11 heartbeatAt: number
12 /** When its current turn began; null while idle. */
13 workingSince: number | null
14 holdReason: HoldReason | null
15 hasEnded: boolean
16}
17
18/** User override on top of the automatic play/pause rule. */
19export type PlaybackOverride = 'none' | 'paused' | 'playing'
20
21/** The one player every session shares: what to play. Only start and stop write it. */
22export type PlayerIntent = {
23 isEnabled: boolean
24 player: PlayerKind
25 playlistId: string
26}
27
28export type PlaybackDecision = {
29 shouldPlay: boolean
30 /** The live session whose pending input keeps the player paused. */
31 holder: SessionRecord | null
32}
33
34export const WORK_THRESHOLD_MS = 10_000
35export const STALE_AFTER_MS = 15_000
36const PRUNE_AFTER_MS = 60 * 60 * 1000
37
38/** Sessions that ended or stopped sending heartbeats are no longer counted. */
39export const liveRecords = (records: readonly SessionRecord[], now: number): SessionRecord[] =>
40 records.filter(record => !record.hasEnded && now - record.heartbeatAt <= STALE_AFTER_MS)
41
42/**
43 * The shared play/pause rule. Any live session awaiting input holds the player
44 * paused, whatever anyone else wants. Otherwise the person's override wins,
45 * and with none the player plays while some session has worked 10 s or more.
46 */
47export const decidePlayback = (
48 intent: PlayerIntent | undefined,
49 override: PlaybackOverride,
50 records: readonly SessionRecord[],
51 now: number,
52): PlaybackDecision => {
53 const live = liveRecords(records, now)
54 const holder = live.find(record => record.holdReason !== null) ?? null
55 if (intent === undefined || !intent.isEnabled || holder !== null) return { shouldPlay: false, holder }
56 if (override !== 'none') return { shouldPlay: override === 'playing', holder }
57 const isAnyoneWorking = live.some(
58 record => record.workingSince !== null && now - record.workingSince >= WORK_THRESHOLD_MS,
59 )
60
61 return { shouldPlay: isAnyoneWorking, holder }
62}
63
64/**
65 * Per-session files under one runtime folder. Each session writes only its own
66 * record, so no lock is needed. The intent is written whole only by start and
67 * stop, and the override sits in a file of its own, so the turn hooks that
68 * clear an override can never put back an intent another session replaced.
69 */
70export class SharedBoard {
71 constructor(
72 private readonly files: SharedFiles,
73 private readonly root: string,
74 ) {}
75
76 get folder(): string {
77 return this.root
78 }
79
80 async publish(record: SessionRecord): Promise<void> {
81 await this.files.write(this.recordPath(record.sessionId), JSON.stringify(record))
82 }
83
84 async records(): Promise<SessionRecord[]> {
85 const names = await this.files.list(this.sessionsDir())
86 const texts = await Promise.all(names.map(name => this.files.read(`${this.sessionsDir()}/${name}`)))
87
88 return texts.flatMap(text => parseJson<SessionRecord>(text) ?? [])
89 }
90
91 async readIntent(): Promise<PlayerIntent | undefined> {
92 return parseJson<PlayerIntent>(await this.files.read(this.intentPath()))
93 }
94
95 async writeIntent(intent: PlayerIntent): Promise<void> {
96 await this.files.write(this.intentPath(), JSON.stringify(intent))
97 }
98
99 /** The person's play/pause override; a file of its own so writing it never rewrites the intent. */
100 async readOverride(): Promise<PlaybackOverride> {
101 return parseJson<PlaybackOverride>(await this.files.read(this.overridePath())) ?? 'none'
102 }
103
104 async writeOverride(override: PlaybackOverride): Promise<void> {
105 await this.files.write(this.overridePath(), JSON.stringify(override))
106 }
107
108 /** Removes records of sessions dead for an hour, so the folder stays small. */
109 async prune(records: readonly SessionRecord[], now: number): Promise<void> {
110 const dead = records.filter(record => record.hasEnded || now - record.heartbeatAt > PRUNE_AFTER_MS)
111 await Promise.all(dead.map(record => this.files.remove(this.recordPath(record.sessionId))))
112 }
113
114 private sessionsDir(): string {
115 return `${this.root}/sessions`
116 }
117
118 private recordPath(sessionId: string): string {
119 return `${this.sessionsDir()}/${sessionId.replace(/[^A-Za-z0-9_-]/g, '_')}.json`
120 }
121
122 private intentPath(): string {
123 return `${this.root}/intent.json`
124 }
125
126 private overridePath(): string {
127 return `${this.root}/override.json`
128 }
129}
130
131/** A half-written file from a concurrent writer reads as missing this tick. */
132const parseJson = <T>(text: string | undefined): T | undefined => {
133 if (text === undefined) return undefined
134 try {
135 return JSON.parse(text) as T
136 } catch {
137 return undefined
138 }
139}
140hooks/ports.ts 74 lines1import type { TunesSnapshot } from '../types'
2import type { LocalFiles } from './browser-cookies'
3
4/** Runs a host command by argv. Rejects when it cannot start or times out. */
5export interface CommandRunner {
6 run(argv: readonly string[], timeoutMs?: number): Promise<{ exitCode: number; stdout: string; stderr: string }>
7}
8
9export type HttpReply = { status: number; ok: boolean; text: string }
10
11/** Plain HTTP(S) requests. Rejects only when the request cannot be made. */
12export interface HttpClient {
13 request(url: string, init: { method: string; headers?: Record<string, string>; body?: string }): Promise<HttpReply>
14}
15
16/** Files shared between every Claude session on this machine. */
17export interface SharedFiles {
18 /** Resolves undefined when the file is missing or unreadable. */
19 read(path: string): Promise<string | undefined>
20 write(path: string, text: string): Promise<void>
21 /** Resolves the file names in `dir`, empty when it is missing. */
22 list(dir: string): Promise<string[]>
23 remove(path: string): Promise<void>
24}
25
26/** This plugin's JSON store, kept across sessions. */
27export interface KeyValueStore {
28 get(key: string): Promise<unknown>
29 set(key: string, value: unknown): Promise<void>
30}
31
32export interface Clock {
33 now(): Promise<number>
34}
35
36/** Where the latest view of the player goes (the pane's `$.state`). */
37export interface SnapshotSink {
38 show(snapshot: TunesSnapshot): Promise<void>
39}
40
41/** Messages to the person outside the pane. */
42export interface Notifier {
43 /** A short toast. */
44 notify(text: string): void
45 /** A dim transcript line, for text they may need to copy. */
46 log(text: string): void
47}
48
49/** Asks the person a multiple-choice question; "Other" answers free text. Rejects when dismissed. */
50export interface Prompter {
51 ask(question: string, options: readonly string[]): Promise<string>
52}
53
54/** Everything the mod needs from the engine, as plain collaborators. */
55export type HostPorts = {
56 runner: CommandRunner
57 http: HttpClient
58 files: SharedFiles
59 /** The person's own files, for finding browser profiles. */
60 localFiles: LocalFiles
61 store: KeyValueStore
62 clock: Clock
63 snapshots: SnapshotSink
64 notifier: Notifier
65 prompter: Prompter
66 /** The plugin's own folder, holding bin/. */
67 pluginRoot: string
68 /** Per-user runtime folder ($XDG_RUNTIME_DIR), shared by every session. */
69 runtimeDir: string
70 /** $HOME, where browser profiles live. */
71 homeDir: string
72 sessionId: string
73}
74hooks/services.ts 104 lines1import type { PlayerKind } from '../types'
2import { BrowserCookieFinder } from './browser-cookies'
3import { SharedBoard } from './coordination'
4import { MpvPlayer } from './players/mpv-player'
5import type { PlayerRegistry } from './players/music-player'
6import { SpotifyAuth, XdgLoginBrowser } from './players/spotify-auth'
7import { SpotifyPlayer } from './players/spotify-player'
8import { PlaybackSync } from './playback-sync'
9import type { SessionIdentity } from './playback-sync'
10import type { CommandRunner, HostPorts, Prompter } from './ports'
11import { SessionActivity } from './session-activity'
12import { TunesCommandFlow } from './tunes-command'
13import type { DependencyCheck } from './tunes-command'
14import { TunesControls } from './tunes-controls'
15import { TunesPreferences } from './tunes-preferences'
16
17export type TunesServices = {
18 activity: SessionActivity
19 board: SharedBoard
20 sync: PlaybackSync
21 controls: TunesControls
22 flow: TunesCommandFlow
23}
24
25/** Wires every collaborator for one session from the host's ports. */
26export const buildServices = (host: HostPorts, cwd: string): TunesServices => {
27 const sharedRoot = `${host.runtimeDir}/coding-tunes`
28 const board = new SharedBoard(host.files, sharedRoot)
29 const auth = new SpotifyAuth(host.http, host.store, host.clock)
30 const preferences = new TunesPreferences(host.store)
31 const players: PlayerRegistry = {
32 ytmusic: new MpvPlayer(host.runner, {
33 helper: `${host.pluginRoot}/bin/mpvctl.py`,
34 socket: `${sharedRoot}/mpv.sock`,
35 lock: `${sharedRoot}/mpv.lock`,
36 }, preferences),
37 spotify: new SpotifyPlayer(host.http, auth),
38 }
39 const identity = sessionIdentity(host.sessionId, cwd)
40 const activity = new SessionActivity()
41 const sync = new PlaybackSync(board, activity, players, host.clock, identity, host.snapshots, host.notifier)
42 const controls = new TunesControls(board, activity, players, sync, identity)
43 const browser = new XdgLoginBrowser(host.runner, `${host.pluginRoot}/bin/oauth_callback.py`, url =>
44 host.notifier.log(`Coding Tunes: if no browser opened, open this page to link Spotify: ${url}`),
45 )
46 const flow = new TunesCommandFlow(
47 controls,
48 preferences,
49 idleOnlyPrompter(host.prompter, activity),
50 { link: clientId => auth.authorize(clientId, browser) },
51 dependencyCheck(host.runner, auth),
52 new BrowserCookieFinder(host.localFiles, host.homeDir),
53 )
54
55 return { activity, board, sync, controls, flow }
56}
57
58/**
59 * The per-user folder the shared player lives in: $XDG_RUNTIME_DIR, else
60 * ~/.cache, else the plugin's own folder. Never a world-writable one such as
61 * /tmp, where another local user could plant the socket or the records first.
62 */
63export const resolveRuntimeDir = (xdgRuntimeDir: string | undefined, homeDir: string, pluginRoot: string): string => {
64 if (xdgRuntimeDir !== undefined && xdgRuntimeDir !== '') return xdgRuntimeDir
65
66 return homeDir === '' ? `${pluginRoot}/.run` : `${homeDir}/.cache`
67}
68
69const MID_TURN = 'Claude is working, so this would open a question over the turn: run it once Claude is idle. While it works, use /coding-tunes --play, --pause, --next or --prev.'
70
71/** Questions wait for an idle Claude: the command runs mid-turn, and a dialog must not open over the turn. */
72const idleOnlyPrompter = (prompter: Prompter, activity: SessionActivity): Prompter => ({
73 ask: (question, options) => (activity.isWorking() ? Promise.reject(new Error(MID_TURN)) : prompter.ask(question, options)),
74})
75
76/** Sessions show each other as `<folder>#<id prefix>`. */
77export const sessionIdentity = (sessionId: string, cwd: string): SessionIdentity => {
78 const folder = cwd.split('/').filter(Boolean).at(-1) ?? 'session'
79
80 return { sessionId, label: `${folder}#${sessionId.slice(0, 4)}` }
81}
82
83const dependencyCheck = (runner: CommandRunner, auth: SpotifyAuth): DependencyCheck => ({
84 missingFor: async (player: PlayerKind) => {
85 if (player === 'spotify') {
86 return auth.accessToken().then(
87 () => undefined,
88 () => 'Spotify is not linked yet: run /coding-tunes --setup and choose Spotify.',
89 )
90 }
91 const missing = await missingTools(runner, ['mpv', 'yt-dlp'])
92
93 return missing.length === 0 ? undefined : `YouTube Music needs ${missing.join(' and ')}: install ${missing.join(' and ')} with your package manager`
94 },
95})
96
97const missingTools = async (runner: CommandRunner, tools: readonly string[]): Promise<string[]> => {
98 const found = await Promise.all(
99 tools.map(tool => runner.run(['which', tool]).then(result => result.exitCode === 0, () => false)),
100 )
101
102 return tools.filter((_, index) => !found[index])
103}
104hooks/tunes-controls.ts 102 lines1import type { PlayerKind } from '../types'
2import type { PlaybackOverride, SharedBoard } from './coordination'
3import type { PlayerRegistry } from './players/music-player'
4import type { PlaybackSync, SessionIdentity } from './playback-sync'
5import type { SessionActivity } from './session-activity'
6
7/** What a control did: done, or refused with the reason to show. */
8export type ControlOutcome = { isDone: true } | { isDone: false; reason: string }
9
10const DONE: ControlOutcome = { isDone: true }
11
12/**
13 * The person's controls over the shared player: start, stop, play, pause,
14 * next, previous. Play respects another session's hold: only the session
15 * awaiting input may resume while it waits.
16 */
17export class TunesControls {
18 constructor(
19 private readonly board: SharedBoard,
20 private readonly activity: SessionActivity,
21 private readonly players: PlayerRegistry,
22 private readonly sync: PlaybackSync,
23 private readonly identity: SessionIdentity,
24 ) {}
25
26 async start(player: PlayerKind, playlistId: string): Promise<ControlOutcome> {
27 const previous = await this.board.readIntent()
28 if (previous?.isEnabled === true && previous.player !== player) await this.players[previous.player].shutdown()
29 await this.board.writeIntent({ isEnabled: true, player, playlistId })
30 await this.board.writeOverride('none')
31 this.sync.forceReload()
32 await this.sync.reconcile()
33
34 return DONE
35 }
36
37 /** Reloads the current playlist, so a changed browser login takes effect. */
38 async reload(): Promise<void> {
39 const intent = await this.board.readIntent()
40 if (intent === undefined || !intent.isEnabled) return
41 this.sync.forceReload()
42 await this.sync.reconcile()
43 }
44
45 async stop(): Promise<ControlOutcome> {
46 const intent = await this.board.readIntent()
47 if (intent === undefined || !intent.isEnabled) return { isDone: false, reason: 'Nothing is playing.' }
48 await this.board.writeIntent({ ...intent, isEnabled: false })
49 await this.players[intent.player].shutdown()
50 await this.sync.reconcile()
51
52 return DONE
53 }
54
55 /** Refused while another session waits on the person's input. */
56 async play(): Promise<ControlOutcome> {
57 const { holder } = await this.sync.currentDecision()
58 if (holder !== null && holder.sessionId !== this.identity.sessionId) {
59 return { isDone: false, reason: `Paused: session "${holder.label}" is waiting for your input.` }
60 }
61 if (holder !== null) this.activity.waiveHold()
62
63 return this.setOverride('playing')
64 }
65
66 pause(): Promise<ControlOutcome> {
67 return this.setOverride('paused')
68 }
69
70 next(): Promise<ControlOutcome> {
71 return this.skip(player => player.next())
72 }
73
74 previous(): Promise<ControlOutcome> {
75 return this.skip(player => player.previous())
76 }
77
78 /** A pause lasts until a turn starts; a forced play until a turn ends. */
79 async clearOverride(expired: PlaybackOverride): Promise<void> {
80 if ((await this.board.readOverride()) !== expired) return
81 await this.board.writeOverride('none')
82 }
83
84 private async setOverride(override: PlaybackOverride): Promise<ControlOutcome> {
85 const intent = await this.board.readIntent()
86 if (intent === undefined || !intent.isEnabled) return { isDone: false, reason: 'Start a playlist with /coding-tunes first.' }
87 await this.board.writeOverride(override)
88 await this.sync.reconcile()
89
90 return DONE
91 }
92
93 private async skip(move: (player: PlayerRegistry[PlayerKind]) => Promise<void>): Promise<ControlOutcome> {
94 const intent = await this.board.readIntent()
95 if (intent === undefined || !intent.isEnabled) return { isDone: false, reason: 'Start a playlist with /coding-tunes first.' }
96 await move(this.players[intent.player])
97 await this.sync.reconcile()
98
99 return DONE
100 }
101}
102hooks/winamp-view.tsx 138 lines1import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
2
3import type { TunesSnapshot } from '../types'
4import { PLAYER_LABELS } from './player-kind'
5
6export type WinampElements = {
7 Box: ElementConstructor<BoxProps>
8 Text: ElementConstructor<TextProps>
9 Button: ElementConstructor<ButtonProps>
10}
11
12export type WinampActions = {
13 previous: () => void
14 play: () => void
15 pause: () => void
16 next: () => void
17}
18
19/** Winamp 2's classic skin: charcoal body, blue title bar, green LCD on black. */
20const SKIN = {
21 body: '#2b2b3a',
22 bevel: '#8c8ca3',
23 titleBar: '#1b2a8a',
24 titleText: '#f2d46b',
25 lcd: '#000000',
26 lcdText: '#00e600',
27 lcdDim: '#007a00',
28 amber: '#ffb000',
29 alert: '#ff4040',
30 barLow: '#00d000',
31 barMid: '#e0e000',
32 barHigh: '#ff3000',
33} as const
34
35const MAX_COLUMNS = 46
36const MIN_COLUMNS = 24
37const BAR_GLYPHS = Array.from(' ▁▂▃▄▅▆▇█')
38const BAR_COUNT = 10
39
40/** Draws the player; `columns` is the body width the pane offers. */
41export const WinampView = (
42 { Box, Text, Button }: WinampElements,
43 snapshot: TunesSnapshot,
44 columns: number,
45 actions: WinampActions,
46): RenderElement => {
47 const width = Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, columns))
48 const inner = width - 4
49
50 return (
51 <Box flexDirection="column" width={width} borderStyle="single" borderColor={SKIN.bevel} backgroundColor={SKIN.body}>
52 <Text backgroundColor={SKIN.titleBar} color={SKIN.titleText} bold wrap="truncate">
53 {centered('▀▄▀ CODING TUNES ▀▄▀', width - 2)}
54 </Text>
55 <Box flexDirection="column" marginX={1} paddingX={1} backgroundColor={SKIN.lcd}>
56 <Box flexDirection="row" justifyContent="space-between">
57 <Text color={SKIN.lcdText} bold>
58 {snapshot.isPlaying ? '▶' : '‖'} {clockText(snapshot.elapsedSeconds)}
59 </Text>
60 <Box flexDirection="row">{spectrum(Text, snapshot)}</Box>
61 </Box>
62 <Text color={SKIN.lcdText} wrap="truncate">
63 {marquee(snapshot.track === '' ? idleTitle(snapshot) : snapshot.track, snapshot.frame, inner)}
64 </Text>
65 <Text color={SKIN.lcdDim} wrap="truncate">
66 {snapshot.player === null ? 'NO PLAYER' : PLAYER_LABELS[snapshot.player].toUpperCase()}
67 {' PL '}
68 {snapshot.playlistId ?? '--'}
69 </Text>
70 </Box>
71 <Box flexDirection="row" justifyContent="center" gap={1}>
72 <Button key="previous" label="|◀" hotkey="z" onPress={actions.previous} />
73 <Button key="play" label="▶" hotkey="x" variant="primary" onPress={actions.play} />
74 <Button key="pause" label="‖" hotkey="c" onPress={actions.pause} />
75 <Button key="next" label="▶|" hotkey="b" onPress={actions.next} />
76 </Box>
77 {statusLine(Text, snapshot)}
78 </Box>
79 )
80}
81
82const statusLine = (Text: WinampElements['Text'], snapshot: TunesSnapshot): RenderElement | null => {
83 if (snapshot.notice !== null) {
84 return <Text key="notice" color={SKIN.alert} wrap="truncate">{snapshot.notice}</Text>
85 }
86 if (snapshot.heldBy !== null) {
87 const who = snapshot.isHeldHere ? 'this session' : `"${snapshot.heldBy}"`
88 return <Text key="held" color={SKIN.amber} wrap="truncate">PAUSED: {who} awaits your input</Text>
89 }
90
91 return null
92}
93
94const spectrum = (Text: WinampElements['Text'], snapshot: TunesSnapshot): RenderElement[] =>
95 barHeights(snapshot).map((height, index) => (
96 <Text key={`bar${index}`} color={height > 6 ? SKIN.barHigh : height > 3 ? SKIN.barMid : SKIN.barLow}>
97 {BAR_GLYPHS[height] ?? ' '}
98 </Text>
99 ))
100
101/** Pseudo-random bar heights from the frame; flat while paused. */
102export const barHeights = (snapshot: TunesSnapshot): number[] =>
103 Array.from({ length: BAR_COUNT }, (_, index) => {
104 if (!snapshot.isPlaying) return 1
105 const seed = Math.sin((snapshot.frame + 1) * 12.9898 + index * 78.233) * 43758.5453
106 const noise = seed - Math.floor(seed)
107 const falloff = 1 - index / (BAR_COUNT * 1.6)
108
109 return Math.max(1, Math.round(noise * 8 * falloff))
110 })
111
112/** Scrolls `text` one step per frame through a window of `width` code points. */
113export const marquee = (text: string, frame: number, width: number): string => {
114 const glyphs = Array.from(text)
115 if (glyphs.length <= width) return text
116 const loop = [...glyphs, ...Array.from(' *** ')]
117 const start = frame % loop.length
118
119 return [...loop, ...loop].slice(start, start + width).join('')
120}
121
122export const clockText = (seconds: number | null): string => {
123 if (seconds === null) return '--:--'
124 const minutes = Math.floor(seconds / 60)
125
126 return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
127}
128
129const idleTitle = (snapshot: TunesSnapshot): string =>
130 snapshot.player === null ? 'Run /coding-tunes to load a playlist' : 'Waiting for Claude to get to work...'
131
132const centered = (text: string, width: number): string => {
133 const length = Array.from(text).length
134 const left = Math.max(0, Math.floor((width - length) / 2))
135
136 return `${' '.repeat(left)}${text}`.padEnd(width, ' ')
137}
138hooks/browser-cookies.ts 112 lines1/** Browsers yt-dlp reads cookies from (`--cookies-from-browser`). */
2export type CookieBrowser = 'firefox' | 'chrome' | 'chromium' | 'brave' | 'edge' | 'opera' | 'vivaldi'
3
4/** A browser login yt-dlp signs YouTube requests with, for private playlists. */
5export type YtCookieSource = {
6 /** What the person sees: "Zen", "Firefox", "Brave". */
7 label: string
8 browser: CookieBrowser
9 /** Absolute profile folder, for Firefox forks yt-dlp does not know by name. */
10 profile?: string
11}
12
13/** Reads the person's own files, outside the shared runtime folder. */
14export interface LocalFiles {
15 /** Resolves undefined when the file is missing or unreadable. */
16 read(path: string): Promise<string | undefined>
17 exists(path: string): Promise<boolean>
18}
19
20/** The saved choice of browser login, or none. */
21export interface CookieSourceSetting {
22 ytCookieSource(): Promise<YtCookieSource | null>
23}
24
25/** The value of yt-dlp's `--cookies-from-browser`. */
26export const cookieSpec = (source: YtCookieSource): string =>
27 source.profile === undefined ? source.browser : `${source.browser}:${source.profile}`
28
29const FIREFOX_FAMILY: readonly { label: string; dir: string }[] = [
30 { label: 'Zen', dir: '.zen' },
31 { label: 'Firefox', dir: '.mozilla/firefox' },
32 { label: 'LibreWolf', dir: '.librewolf' },
33 { label: 'Floorp', dir: '.floorp' },
34]
35
36const CHROMIUM_FAMILY: readonly { label: string; browser: CookieBrowser; dir: string }[] = [
37 { label: 'Chrome', browser: 'chrome', dir: '.config/google-chrome' },
38 { label: 'Brave', browser: 'brave', dir: '.config/BraveSoftware/Brave-Browser' },
39 { label: 'Chromium', browser: 'chromium', dir: '.config/chromium' },
40 { label: 'Vivaldi', browser: 'vivaldi', dir: '.config/vivaldi' },
41 { label: 'Edge', browser: 'edge', dir: '.config/microsoft-edge' },
42 { label: 'Opera', browser: 'opera', dir: '.config/opera' },
43]
44
45type IniSections = Map<string, Map<string, string>>
46
47/**
48 * The profile folder a Firefox-family `profiles.ini` starts by default:
49 * the install's `Default`, else the profile marked `Default=1`, else the first.
50 * Relative paths are resolved against `root`.
51 */
52export const defaultProfileDir = (profilesIni: string, root: string): string | undefined => {
53 const sections = parseIni(profilesIni)
54 const profiles = [...sections].filter(([name]) => name.startsWith('Profile')).map(([, keys]) => keys)
55 const installDefault = [...sections].find(([name, keys]) => name.startsWith('Install') && keys.has('Default'))?.[1].get('Default')
56 if (installDefault !== undefined) return resolveProfile(installDefault, true, root)
57 const chosen = profiles.find(keys => keys.get('Default') === '1') ?? profiles[0]
58 const path = chosen?.get('Path')
59 if (chosen === undefined || path === undefined) return undefined
60
61 return resolveProfile(path, chosen.get('IsRelative') !== '0', root)
62}
63
64/** Finds the browser logins on this machine that yt-dlp can read. */
65export class BrowserCookieFinder {
66 constructor(
67 private readonly files: LocalFiles,
68 private readonly homeDir: string,
69 ) {}
70
71 async find(): Promise<YtCookieSource[]> {
72 const firefox = await Promise.all(FIREFOX_FAMILY.map(family => this.firefoxProfile(family.label, family.dir)))
73 const chromium = await Promise.all(
74 CHROMIUM_FAMILY.map(async family =>
75 (await this.files.exists(`${this.homeDir}/${family.dir}`)) ? { label: family.label, browser: family.browser } : undefined,
76 ),
77 )
78
79 return [...firefox, ...chromium].filter((source): source is YtCookieSource => source !== undefined)
80 }
81
82 private async firefoxProfile(label: string, dir: string): Promise<YtCookieSource | undefined> {
83 const root = `${this.homeDir}/${dir}`
84 const ini = await this.files.read(`${root}/profiles.ini`)
85 const profile = ini === undefined ? undefined : defaultProfileDir(ini, root)
86 if (profile === undefined || !(await this.files.exists(`${profile}/cookies.sqlite`))) return undefined
87
88 return { label, browser: 'firefox', profile }
89 }
90}
91
92const resolveProfile = (path: string, isRelative: boolean, root: string): string =>
93 isRelative ? `${root}/${path}` : path
94
95const parseIni = (text: string): IniSections => {
96 const sections: IniSections = new Map()
97 let current: Map<string, string> | undefined
98 for (const rawLine of text.split(/\r?\n/)) {
99 const line = rawLine.trim()
100 const header = /^\[(.+)\]$/.exec(line)
101 if (header !== null) {
102 current = new Map()
103 sections.set(header[1] ?? '', current)
104 continue
105 }
106 const separator = line.indexOf('=')
107 if (current !== undefined && separator > 0) current.set(line.slice(0, separator), line.slice(separator + 1))
108 }
109
110 return sections
111}
112hooks/players/mpv-player.ts 78 lines1import { cookieSpec } from '../browser-cookies'
2import type { CookieSourceSetting } from '../browser-cookies'
3import type { CommandRunner } from '../ports'
4import { PlayerError } from './music-player'
5import type { MusicPlayer, NowPlaying } from './music-player'
6
7export type MpvPaths = {
8 /** bin/mpvctl.py inside the plugin. */
9 helper: string
10 socket: string
11 /** Serialises starting mpv so two sessions never start two players. */
12 lock: string
13}
14
15const EXIT_NOT_RUNNING = 2
16const EXIT_MISSING_DEPENDENCY = 3
17const START_TIMEOUT_MS = 20_000
18
19export const youTubeMusicPlaylistUrl = (playlistId: string): string =>
20 `https://music.youtube.com/playlist?list=${encodeURIComponent(playlistId)}`
21
22/**
23 * YouTube Music through one detached mpv (with yt-dlp) shared by every
24 * session, driven over its IPC socket by bin/mpvctl.py.
25 */
26export class MpvPlayer implements MusicPlayer {
27 readonly kind = 'ytmusic' as const
28
29 constructor(
30 private readonly runner: CommandRunner,
31 private readonly paths: MpvPaths,
32 private readonly cookies: CookieSourceSetting,
33 ) {}
34
35 /** Rejects with PlayerError `missing-dependency` or `load-failed`. */
36 async load(playlistId: string): Promise<void> {
37 const url = youTubeMusicPlaylistUrl(playlistId)
38 const source = await this.cookies.ytCookieSource()
39 const argv = ['flock', this.paths.lock, ...this.helperArgv('ensure', url, source === null ? '' : cookieSpec(source))]
40 const result = await this.runner.run(argv, START_TIMEOUT_MS)
41 if (result.exitCode === EXIT_MISSING_DEPENDENCY) {
42 throw new PlayerError('missing-dependency', `${result.stderr.trim()}: install mpv and yt-dlp with your package manager`)
43 }
44 if (result.exitCode !== 0) throw new PlayerError('load-failed', 'mpv did not start')
45 }
46
47 play = (): Promise<void> => this.control('play')
48 pause = (): Promise<void> => this.control('pause')
49 next = (): Promise<void> => this.control('next')
50 previous = (): Promise<void> => this.control('prev')
51
52 /** Rejects with PlayerError `load-failed` when YouTube refused the playlist. */
53 async nowPlaying(): Promise<NowPlaying | null> {
54 const result = await this.runner.run(this.helperArgv('status'))
55 if (result.exitCode !== 0) return null
56 const { hasFailed, ...playing } = JSON.parse(result.stdout) as NowPlaying & { hasFailed?: boolean }
57 if (hasFailed === true) {
58 throw new PlayerError('load-failed', 'YouTube could not open the playlist (private, deleted, a wrong id, or no network); retrying shortly')
59 }
60
61 return playing
62 }
63
64 async shutdown(): Promise<void> {
65 await this.runner.run(this.helperArgv('quit'))
66 }
67
68 /** Rejects with PlayerError `unavailable` when mpv is not running. */
69 private async control(command: string): Promise<void> {
70 const result = await this.runner.run(this.helperArgv(command))
71 if (result.exitCode === EXIT_NOT_RUNNING) throw new PlayerError('unavailable', 'mpv is not running')
72 }
73
74 private helperArgv(...args: string[]): string[] {
75 return ['python3', '-I', this.paths.helper, this.paths.socket, ...args]
76 }
77}
78hooks/players/music-player.ts 50 lines1import type { PlayerKind } from '../../types'
2
3export type NowPlaying = {
4 track: string
5 isPlaying: boolean
6 elapsedSeconds: number | null
7}
8
9/** Why a player could not do what was asked. */
10export type PlayerFailure =
11 | 'missing-dependency'
12 | 'not-authorized'
13 | 'premium-required'
14 | 'no-device'
15 | 'load-failed'
16 | 'unavailable'
17
18export class PlayerError extends Error {
19 constructor(
20 readonly failure: PlayerFailure,
21 message: string,
22 ) {
23 super(message)
24 this.name = 'PlayerError'
25 }
26}
27
28/**
29 * One music service's controls. Every method may reject with PlayerError:
30 * `missing-dependency` (mpv or yt-dlp absent), `not-authorized` (no or
31 * expired Spotify login), `premium-required`, `no-device` (no open Spotify
32 * client), `load-failed` (the player or the playlist would not open),
33 * `unavailable` (the player did not answer).
34 */
35export interface MusicPlayer {
36 readonly kind: PlayerKind
37 /** Makes `playlistId` the loaded playlist, paused, starting the player if needed. */
38 load(playlistId: string): Promise<void>
39 play(): Promise<void>
40 pause(): Promise<void>
41 next(): Promise<void>
42 previous(): Promise<void>
43 /** Resolves null when nothing is loaded. */
44 nowPlaying(): Promise<NowPlaying | null>
45 /** Stops the shared player; called when the last session ends or on --stop. */
46 shutdown(): Promise<void>
47}
48
49export type PlayerRegistry = Readonly<Record<PlayerKind, MusicPlayer>>
50hooks/players/spotify-auth.ts 136 lines1import type { Clock, CommandRunner, HttpClient, KeyValueStore } from '../ports'
2import { PlayerError } from './music-player'
3
4export const SPOTIFY_REDIRECT_PORT = 8898
5export const SPOTIFY_REDIRECT_URI = `http://127.0.0.1:${SPOTIFY_REDIRECT_PORT}/callback`
6const SCOPES = 'user-read-playback-state user-modify-playback-state user-read-currently-playing'
7const TOKEN_URL = 'https://accounts.spotify.com/api/token'
8const TOKENS_KEY = 'spotifyTokens'
9const REFRESH_MARGIN_MS = 60_000
10const LOGIN_TIMEOUT_S = 300
11
12type StoredTokens = {
13 clientId: string
14 accessToken: string
15 refreshToken: string
16 expiresAt: number
17}
18
19type TokenReply = { access_token: string; refresh_token?: string; expires_in: number }
20
21/** Opens the login page and waits for the redirect's code. */
22export interface LoginBrowser {
23 /** Resolves the authorization code; rejects when denied or timed out. */
24 login(authorizeUrl: string, state: string): Promise<string>
25}
26
27/** Supplies a valid Spotify access token. */
28export interface AccessTokenSource {
29 /** Rejects with PlayerError `not-authorized` when no usable login exists. */
30 accessToken(): Promise<string>
31}
32
33/** Spotify's Authorization Code with PKCE flow; tokens kept in the plugin store. */
34export class SpotifyAuth implements AccessTokenSource {
35 constructor(
36 private readonly http: HttpClient,
37 private readonly store: KeyValueStore,
38 private readonly clock: Clock,
39 ) {}
40
41 async accessToken(): Promise<string> {
42 const tokens = (await this.store.get(TOKENS_KEY)) as StoredTokens | undefined
43 if (tokens === undefined) throw new PlayerError('not-authorized', 'Spotify is not linked: run /coding-tunes --setup')
44 if (tokens.expiresAt - REFRESH_MARGIN_MS > (await this.clock.now())) return tokens.accessToken
45
46 return (await this.refresh(tokens)).accessToken
47 }
48
49 /** Rejects with PlayerError `not-authorized` when the login fails. */
50 async authorize(clientId: string, browser: LoginBrowser): Promise<void> {
51 const verifier = randomUrlSafe(64)
52 const state = randomUrlSafe(16)
53 const params = new URLSearchParams({
54 client_id: clientId,
55 response_type: 'code',
56 redirect_uri: SPOTIFY_REDIRECT_URI,
57 scope: SCOPES,
58 code_challenge_method: 'S256',
59 code_challenge: await codeChallenge(verifier),
60 state,
61 })
62 const code = await browser.login(`https://accounts.spotify.com/authorize?${params}`, state)
63 const reply = await this.requestTokens({
64 grant_type: 'authorization_code',
65 code,
66 redirect_uri: SPOTIFY_REDIRECT_URI,
67 client_id: clientId,
68 code_verifier: verifier,
69 })
70 await this.save(clientId, reply, undefined)
71 }
72
73 private async refresh(tokens: StoredTokens): Promise<StoredTokens> {
74 const reply = await this.requestTokens({
75 grant_type: 'refresh_token',
76 refresh_token: tokens.refreshToken,
77 client_id: tokens.clientId,
78 })
79
80 return this.save(tokens.clientId, reply, tokens.refreshToken)
81 }
82
83 private async requestTokens(form: Record<string, string>): Promise<TokenReply> {
84 const reply = await this.http.request(TOKEN_URL, {
85 method: 'POST',
86 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
87 body: new URLSearchParams(form).toString(),
88 })
89 if (!reply.ok) throw new PlayerError('not-authorized', `Spotify login failed (${reply.status}): run /coding-tunes --setup`)
90
91 return JSON.parse(reply.text) as TokenReply
92 }
93
94 private async save(clientId: string, reply: TokenReply, previousRefresh: string | undefined): Promise<StoredTokens> {
95 const tokens: StoredTokens = {
96 clientId,
97 accessToken: reply.access_token,
98 refreshToken: reply.refresh_token ?? previousRefresh ?? '',
99 expiresAt: (await this.clock.now()) + reply.expires_in * 1000,
100 }
101 await this.store.set(TOKENS_KEY, tokens)
102
103 return tokens
104 }
105}
106
107/** Opens the page with xdg-open and catches the redirect with bin/oauth_callback.py. */
108export class XdgLoginBrowser implements LoginBrowser {
109 constructor(
110 private readonly runner: CommandRunner,
111 private readonly callbackHelper: string,
112 private readonly announce: (url: string) => void,
113 ) {}
114
115 async login(authorizeUrl: string, state: string): Promise<string> {
116 this.announce(authorizeUrl)
117 void this.runner.run(['xdg-open', authorizeUrl], 10_000).catch(() => undefined)
118 const argv = ['python3', '-I', this.callbackHelper, String(SPOTIFY_REDIRECT_PORT), state, String(LOGIN_TIMEOUT_S)]
119 const result = await this.runner.run(argv, (LOGIN_TIMEOUT_S + 10) * 1000)
120 if (result.exitCode !== 0) throw new PlayerError('not-authorized', 'Spotify login was denied or timed out')
121
122 return result.stdout.trim()
123 }
124}
125
126const URL_SAFE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
127
128const randomUrlSafe = (length: number): string =>
129 Array.from(crypto.getRandomValues(new Uint8Array(length)), byte => URL_SAFE[byte % URL_SAFE.length]).join('')
130
131const codeChallenge = async (verifier: string): Promise<string> => {
132 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
133
134 return btoa(String.fromCharCode(...digest)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
135}
136hooks/players/spotify-player.ts 91 lines1import type { HttpClient, HttpReply } from '../ports'
2import { PlayerError } from './music-player'
3import type { MusicPlayer, NowPlaying } from './music-player'
4import type { AccessTokenSource } from './spotify-auth'
5
6const API = 'https://api.spotify.com/v1/me/player'
7
8type PlaybackState = {
9 is_playing: boolean
10 progress_ms: number | null
11 context: { uri: string } | null
12 item: { name: string; artists?: { name: string }[] } | null
13}
14
15/**
16 * Spotify through the Web API, playing on whichever Spotify client the
17 * account has active (Premium only). The account itself is the shared player.
18 */
19export class SpotifyPlayer implements MusicPlayer {
20 readonly kind = 'spotify' as const
21 private contextUri: string | null = null
22
23 constructor(
24 private readonly http: HttpClient,
25 private readonly tokens: AccessTokenSource,
26 ) {}
27
28 async load(playlistId: string): Promise<void> {
29 this.contextUri = `spotify:playlist:${playlistId}`
30 }
31
32 /** Starts the loaded playlist, or resumes it when it is already the context. */
33 async play(): Promise<void> {
34 const state = await this.state()
35 const isLoaded = this.contextUri === null || state?.context?.uri === this.contextUri
36 const body = isLoaded ? undefined : JSON.stringify({ context_uri: this.contextUri })
37 await this.send('PUT', '/play', body)
38 }
39
40 async pause(): Promise<void> {
41 const state = await this.state()
42 if (state?.is_playing === true) await this.send('PUT', '/pause')
43 }
44
45 next = (): Promise<void> => this.send('POST', '/next')
46 previous = (): Promise<void> => this.send('POST', '/previous')
47
48 async nowPlaying(): Promise<NowPlaying | null> {
49 const state = await this.state()
50 if (state === null || state.item === null) return null
51 const artists = (state.item.artists ?? []).map(artist => artist.name).join(', ')
52
53 return {
54 track: artists === '' ? state.item.name : `${artists} - ${state.item.name}`,
55 isPlaying: state.is_playing,
56 elapsedSeconds: state.progress_ms === null ? null : Math.floor(state.progress_ms / 1000),
57 }
58 }
59
60 async shutdown(): Promise<void> {
61 await this.pause()
62 }
63
64 private async state(): Promise<PlaybackState | null> {
65 const reply = await this.call('GET', '')
66
67 return reply.status === 204 || reply.text === '' ? null : (JSON.parse(reply.text) as PlaybackState)
68 }
69
70 private async send(method: string, path: string, body?: string): Promise<void> {
71 await this.call(method, path, body)
72 }
73
74 /** Rejects with PlayerError `not-authorized`, `premium-required`, `no-device` or `unavailable`. */
75 private async call(method: string, path: string, body?: string): Promise<HttpReply> {
76 const headers: Record<string, string> = { Authorization: `Bearer ${await this.tokens.accessToken()}` }
77 if (body !== undefined) headers['Content-Type'] = 'application/json'
78 const reply = await this.http.request(`${API}${path}`, { method, headers, body })
79 if (reply.ok) return reply
80 throw failureFor(reply)
81 }
82}
83
84const failureFor = (reply: HttpReply): PlayerError => {
85 if (reply.status === 401) return new PlayerError('not-authorized', 'Spotify login expired: run /coding-tunes --setup')
86 if (reply.status === 404) return new PlayerError('no-device', 'No Spotify device: open Spotify somewhere')
87 if (reply.status === 403) return new PlayerError('premium-required', 'Spotify playback control needs Premium')
88
89 return new PlayerError('unavailable', `Spotify answered ${reply.status}`)
90}
91hooks/playback-sync.ts 166 lines1import { decidePlayback, liveRecords } from './coordination'
2import type { PlaybackDecision, PlayerIntent, SharedBoard } from './coordination'
3import { PlayerError } from './players/music-player'
4import type { MusicPlayer, NowPlaying, PlayerFailure, PlayerRegistry } from './players/music-player'
5import type { Clock, Notifier, SnapshotSink } from './ports'
6import type { SessionActivity } from './session-activity'
7
8export type SessionIdentity = { sessionId: string; label: string }
9
10/** How long a failed load waits before the player is asked to load again. */
11export const LOAD_RETRY_MS = 30_000
12
13/** Failures that re-running the load every tick would only repeat; other errors retry on the next tick. */
14const WAIT_BEFORE_RETRY: ReadonlySet<PlayerFailure> = new Set(['load-failed', 'missing-dependency'])
15
16/**
17 * Keeps the shared player in step with every session's activity: publishes
18 * this session's record, applies the shared play/pause rule on each change of
19 * decision, and refreshes the pane. Calls are serialised.
20 */
21export class PlaybackSync {
22 private queue: Promise<void> = Promise.resolve()
23 private loadedKey: string | null = null
24 private appliedDecision: boolean | null = null
25 private lastNotice: string | null = null
26 private retryLoadAt = 0
27 private hasEnded = false
28 private frame = 0
29
30 constructor(
31 private readonly board: SharedBoard,
32 private readonly activity: SessionActivity,
33 private readonly players: PlayerRegistry,
34 private readonly clock: Clock,
35 private readonly identity: SessionIdentity,
36 private readonly sink: SnapshotSink,
37 private readonly notifier: Notifier,
38 ) {}
39
40 /** Runs one sync after any already queued; never rejects. A no-op once ended. */
41 reconcile(): Promise<void> {
42 this.queue = this.queue
43 .then(() => (this.hasEnded ? undefined : this.syncOnce()))
44 .catch(error => this.report(error))
45
46 return this.queue
47 }
48
49 /** Forgets what was loaded and applied, so the next sync loads and decides afresh. */
50 forceReload(): void {
51 this.loadedKey = null
52 this.appliedDecision = null
53 this.retryLoadAt = 0
54 }
55
56 /** The current decision, for user controls that must respect holds. */
57 async currentDecision(): Promise<PlaybackDecision> {
58 const now = await this.clock.now()
59
60 return decidePlayback(await this.board.readIntent(), await this.board.readOverride(), await this.board.records(), now)
61 }
62
63 /**
64 * Marks this session ended, after any queued sync and before none: later
65 * reconciles do nothing, so no tick can republish this session as live.
66 * The last live session shuts the player down.
67 */
68 end(): Promise<void> {
69 const ending = this.queue.then(() => this.endOnce())
70 this.hasEnded = true
71 this.queue = ending.catch(() => undefined)
72
73 return ending
74 }
75
76 private async endOnce(): Promise<void> {
77 const now = await this.clock.now()
78 await this.board.publish({ ...this.activity.toRecord(this.identity.sessionId, this.identity.label, now), hasEnded: true })
79 const others = liveRecords(await this.board.records(), now).filter(
80 record => record.sessionId !== this.identity.sessionId,
81 )
82 const intent = await this.board.readIntent()
83 if (others.length === 0 && intent?.isEnabled === true) await this.players[intent.player].shutdown()
84 }
85
86 private async syncOnce(): Promise<void> {
87 const now = await this.clock.now()
88 await this.board.publish(this.activity.toRecord(this.identity.sessionId, this.identity.label, now))
89 const records = await this.board.records()
90 const intent = await this.board.readIntent()
91 const decision = decidePlayback(intent, await this.board.readOverride(), records, now)
92 const isWaitingToRetry = this.loadedKey === null && now < this.retryLoadAt
93 const playing =
94 intent?.isEnabled !== true
95 ? null
96 : isWaitingToRetry
97 ? await this.pauseWhileWaiting(intent, decision)
98 : await this.drive(intent, decision)
99 await this.board.prune(records, now)
100 await this.showSnapshot(intent, decision, playing, isWaitingToRetry ? this.lastNotice : null)
101 }
102
103 private async drive(intent: PlayerIntent, decision: PlaybackDecision): Promise<NowPlaying | null> {
104 const player = this.players[intent.player]
105 await this.ensureLoaded(player, intent)
106 if (decision.shouldPlay !== this.appliedDecision) {
107 await (decision.shouldPlay ? player.play() : player.pause())
108 this.appliedDecision = decision.shouldPlay
109 }
110 const playing = await player.nowPlaying()
111 if (playing === null) this.loadedKey = null
112
113 return playing
114 }
115
116 /** The reload waits, but a hold or a pause must still stop the music. */
117 private async pauseWhileWaiting(intent: PlayerIntent, decision: PlaybackDecision): Promise<null> {
118 if (!decision.shouldPlay) await this.players[intent.player].pause().catch(() => undefined)
119 this.appliedDecision = null
120
121 return null
122 }
123
124 private async ensureLoaded(player: MusicPlayer, intent: PlayerIntent): Promise<void> {
125 const key = `${intent.player}:${intent.playlistId}`
126 if (this.loadedKey === key) return
127 await player.load(intent.playlistId)
128 this.loadedKey = key
129 this.appliedDecision = null
130 }
131
132 private async report(error: unknown): Promise<void> {
133 const notice = error instanceof PlayerError ? error.message : `Coding Tunes: ${String(error)}`
134 if (notice !== this.lastNotice) this.notifier.notify(notice)
135 this.lastNotice = notice
136 this.loadedKey = null
137 if (error instanceof PlayerError && WAIT_BEFORE_RETRY.has(error.failure)) {
138 this.retryLoadAt = (await this.clock.now().catch(() => 0)) + LOAD_RETRY_MS
139 }
140 const intent = await this.board.readIntent().catch(() => undefined)
141 await this.showSnapshot(intent, { shouldPlay: false, holder: null }, null, notice).catch(() => undefined)
142 }
143
144 private async showSnapshot(
145 intent: PlayerIntent | undefined,
146 decision: PlaybackDecision,
147 playing: NowPlaying | null,
148 notice: string | null,
149 ): Promise<void> {
150 if (notice === null) this.lastNotice = null
151 this.frame += 1
152 const holder = decision.holder
153 await this.sink.show({
154 player: intent?.isEnabled === true ? intent.player : null,
155 playlistId: intent?.isEnabled === true ? intent.playlistId : null,
156 track: playing?.track ?? '',
157 isPlaying: playing?.isPlaying ?? false,
158 elapsedSeconds: playing?.elapsedSeconds ?? null,
159 heldBy: holder?.label ?? null,
160 isHeldHere: holder?.sessionId === this.identity.sessionId,
161 notice,
162 frame: this.frame,
163 })
164 }
165}
166