SLOPSHOPPER

coding-tunes

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…

paneguardcommandtoastprocessnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · coding-tunes
│ ┃ coding-tunes ✕ › fix the failing auth test and add an audit log call │ ┃ ┌─────────────────────────────────────────── │ ┃ │ ▀▄▀ CODING TUNES ▀▄▀ ⏺ Read(src/auth.ts) │ ┃ │ ‖ --:-- ▁▁▁▁▁▁▁▁▁▁ ⎿ Read 6 lines │ ┃ │ Run /coding-tunes to load a playlist ⏺ Update(src/auth.ts) │ ┃ │ NO PLAYER PL -- ⎿ Added 2 lines, removed 1 line │ ┃ │ [ |◀ ] [ ▶ ] [ ‖ ] [ ▶| ] ⏺ Bash(bun test) │ ┃ └─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /coding-tunes │ ⎿ coding-tunes: YouTube Music playlist PLQtABrboQ1MRguCL9d1Z7_v3ow │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · coding-tunes
┌────────────────────────────────────────────┐ │ ▀▄▀ CODING TUNES ▀▄▀ │ │ ‖ --:-- ▁▁▁▁▁▁▁▁▁▁ │ │ Run /coding-tunes to load a playlist │ │ NO PLAYER PL -- │ │ [ |◀ ] [ ▶ ] [ ‖ ] [ ▶| ] │ └────────────────────────────────────────────┘
README

coding-tunes

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.

Install

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.

Use

CommandWhat it does
/coding-tunes --player=ytmusic --list=<playlist id>Loads the playlist (--player=spotify for Spotify). An id or a link both work.
/coding-tunesAsks for the player and the playlist.
/coding-tunes --setupSaves the default player and playlist. For YouTube Music it offers a browser login, for Spotify it links your account.
/coding-tunes --play / --pause / --next / --prevThe pane's buttons as commands. They run at once, even while Claude is working.
/coding-tunes --stopStops 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.

When it plays

  • It plays once a turn has run for 10 seconds, and pauses when the turn ends.
  • It pauses as soon as a permission dialog or a question (AskUserQuestion) needs you, and resumes once it is answered.
  • Shared across sessions. Every Claude session on the machine drives one player. While any session waits on your input, the player stays paused and no other session can resume it, not even with its play button. The waiting session may press play itself.
  • Your buttons. Pause lasts until the next turn starts; play lasts until the next turn ends. Previous and next skip at any time.
  • Pane hotkeys, while the pane has focus (ctrl+x tab): 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.

Players

YouTube Music

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.

Spotify

Needs Spotify Premium (the Web API's playback controls require it), and a Spotify client open somewhere for the sound to come from.

  1. Create an app at developer.spotify.com with the redirect URI http://127.0.0.1:8898/callback.
  2. Run /coding-tunes --setup, choose Spotify, and paste the app's client id under "Other".
  3. Sign in on the page that opens. The tokens are kept in the plugin's store.

Where the pane sits

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.

Files it writes

  • $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.
  • The plugin store: defaults, recent playlists, the browser-login choice, and Spotify tokens.

Development

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.

Source 18 files
hooks/register.tsx 229 lines
1import 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}
229
hooks/coordination.ts 140 lines
1import 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}
140
hooks/ports.ts 74 lines
1import 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}
74
hooks/services.ts 104 lines
1import 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}
104
hooks/tunes-controls.ts 102 lines
1import 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}
102
hooks/winamp-view.tsx 138 lines
1import 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}
138
hooks/browser-cookies.ts 112 lines
1/** 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}
112
hooks/players/mpv-player.ts 78 lines
1import { 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}
78
hooks/players/music-player.ts 50 lines
1import 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>>
50
hooks/players/spotify-auth.ts 136 lines
1import 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}
136
hooks/players/spotify-player.ts 91 lines
1import 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}
91
hooks/playback-sync.ts 166 lines
1import { 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