SLOPSHOPPER

jukebox7

Plays music from YouTube when asked in plain words, audio only through a hidden VLC, with a small control pane

newpanecommandtoaststatusprompt
★ 1v0.2.0MITupdated 2026-10-08KTCrisis/flux7-mods/jukebox7
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jukebox7
│ ┃ jukebox7 ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ■ Nothing plays. ⏺ Read(src/auth.ts) │ ┃ │ 1: ambient 2: alt 90s 3: metal 4: synth ⎿ Read 6 lines │ ┃ ╰─────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ 3 ミ ケ ク ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ュ キ シ ⎿ 3 pass, 1 fail │ ┃ マ │ ┃ 9 ッ イ ヤ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ウ │ ┃ ワ チ ョ マ ✻ Worked for 42s · done 4:20 PM │ ┃ キ │ ┃ ォ ヨ ネ シ › /music │ ┃ ハ ⎿ jukebox7: Nothing played from here yet: pick a genre in the pane │ ┃ ニ ヤ ウ ユ │ ┃ ン │ ┃ フ 6 メ ョ │ ┃ ゥ │ ┃ シ カ ハ ム │ ┃ 9 │ ┃ 4 レ ョ カ │ ┃ │ ┃ ヲ オ │ ┃ ヒ │ ┃ シ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jukebox7
╭─────────────────────────────────────────────────────────── │ ■ Nothing plays. │ 1: ambient 2: alt 90s 3: metal 4: synth & electro 5: i ╰─────────────────────────────────────────────────────────── 3 ミ ケ ク ュ キ シ マ 9 ッ イ ヤ ウ ワ チ ョ マ キ ォ ヨ ネ シ ハ ニ ヤ ウ ユ ン フ 6 メ ョ ゥ シ カ ハ ム 9 4 レ ョ カ ヲ オ ヒ シ リ エ 7 7 ン 7 シ リ ン ァ ヘ ン ハ ル ゥ コ ソ ア テ ネ コ ヒ ヒ キ ナ ユ マ ヌ ョ チ ヲ ネ タ ホ コ テ ミ 8 マ セ ハ コ ン ハ ヨ ケ レ ス ム シ ノ サ ラ ソ 4 シ ェ キ 5 ワ ワ ッ サ シ サ ヘ 7 7 ト ス キ ィ ソ ツ オ ュ ユ 0 ワ サ 3 カ マ チ
README

jukebox7

Music on demand inside a Claude Code session, asked in plain words, played without a browser.

What it does

A prompt that names music (put on some ambient, coupe la musique, next) goes to Haiku first, which answers one intent: play, toggle, next, similar, volume, stop, or none. Anything else, including requests aimed at play7, keys7, Renoise or a melody, reaches the session untouched; a prompt without a music word skips the model call altogether.

  • play: yt-dlp searches YouTube, keeps songs of two to twenty minutes (a mood asked in words prefers five and more), and streams the audio into Windows' VLC with its dummy interface: no window, no focus taken. The track asked for plays whatever its length; next walks the songs alone, and past the last one the list ends.
  • toggle / next / stop: the WSL side runs in a process group of its own (setsid); pause is VLC's own pl_forcepause over its HTTP interface, at once, stop is CONT then TERM, and the vlc.exe it started is terminated by its --meta-title=jukebox7 tag, so a VLC opened by hand is never touched. A pause VLC does not answer changes nothing and says so. One change of song runs at a time (a search takes up to 40 s): a press meanwhile is refused. A session that never played leaves the VLC alone when it ends.

The pane (/music, or opened on the first song) shows the title, its state and volume, p pause, n next, r similar, s stop, d and u the volume by 10 %, and the genre buttons 1 to 9 and 0: ambient, alt 90s, metal, synth & electro, idm, indie rock, video games, film & modern classical, hip-hop & abstract, post-rock. A genre is a radio: a random artist from its list, a random song of theirs; next and the end of a song roll again.

A pick searches YouTube Music's songs tab, which holds tracks only: no interview, gameplay or full OST. Its listing gives no length: yt-dlp is asked for it once the song plays, the bar shows the elapsed time until then; plain YouTube, kept to two to twenty minutes, is the fallback when it finds nothing.

Discoveries. Each genre also holds a few artists beyond the listener's own, drawn from the lists' neighbors (Grouper and Tim Hecker by Loscil and Brian Eno, Deathspell Omega and Panopticon by Blut Aus Nord and Agalloch, Sewerslvt and Machine Girl by Venetian Snares, Car Seat Headrest and Alex G by Modest Mouse and Elliott Smith). One pick in three comes from there and its title wears a ✦; strike a name from discover to drop it.

similar (r, /music similar, or in words, plus comme ça) plays the YouTube Music radio of the song on: other artists first (the radio lingers on the same one), covers, tributes and hour-long loops dropped; next walks that list.

On the terminal the pane is black down to its last row, framed in the avatar's color, and the room left under the player fills with a green code rain in the manner of Ghost in the Shell: half-width katakana and digits, falling five frames a second while a song plays, frozen on pause.

With avatar7 loaded, a plays the pick of the avatar on duty: the station of its persona.json, which avatar7 publishes in its station state. Each song that starts, asked, from a genre or chained when the last one ends, is written to this mod's say state: avatar7, if loaded, introduces it in the persona's manner, like a radio host over the intro. While the avatar speaks (its isVoicing state) the music drops to 70 % of its level, then returns.

/music <search> plays one named track; /music pause|next|stop; /music vol 60, /music vol +20. In words, monte le son works too.

Only a short prompt (eight words at most) with a music word or phrase goes to Haiku; a longer one is talk to the assistant and passes untouched, even when it mentions music.

The volume goes through VLC's HTTP interface on Windows' loopback (port 18797), reached with Windows' own curl.exe. The level is kept apart from the player, survives a stop, and is set again on each new song: a fresh VLC starts at whatever level Windows kept for it.

Requirements

WSL with Windows interop, yt-dlp on the path, VLC at C:\Program Files\VideoLAN\VLC. VLC rather than ffplay on WSLg's PulseAudio: that server stalls after a sleep (it takes audio at a tenth of real time and nothing comes out) until wsl --shutdown.

Limits

  • The genre lists are one listener's; edit GENRES in hooks/register.tsx. A station belongs to its persona, in avatar7's personas/<id>/persona.json.
  • A very long pause may let YouTube drop the stream behind VLC, which then ends the song early on resume.
  • A song that ends moves on within five seconds (the liveness poll).

Tests

claude plugin test ~/flux7-mods/jukebox7

Source 3 files
hooks/register.tsx 1028 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Found, ModelUse, Player, Say, Track } from '../types'
5import { drainArgv, heldArgv, parseJukebox, pauseScript, REMOTE, statusArgv } from './remote'
6
7// Cheap sieve before any model call: a prompt that fails it reaches the
8// session untouched, with no added latency. A command to the jukebox is
9// short; a longer message is talk to the assistant, even about music (one
10// about ducking the music was once taken for a volume change). Common words
11// (son, fort, mets, lance) only count inside a music phrase; a prompt that
12// opens on joue or play names what to play.
13const MAX_WORDS = 8
14const MUSIC =
15  /\b(musique|music|morceau|chanson|playlist|youtube|ambient|lofi|radio|volume|pause|stop|coupe|arr[eê]te|reprends|resume|suivant|next|skip|louder|quieter|similar|similaire|pareil)\b|\b(monte|baisse|coupe)[sz]?\s+(le\s+)?son\b|\b(plus|moins)\s+fort\b|\b(joue|jouer|mets|lance|play)\b.*\b(du|des|de\s+la|un\s+peu\s+de|some)\b|^\s*(joue|play)\b/i
16export const isCandidate = (text: string): boolean => text.trim().split(/\s+/).length <= MAX_WORDS && MUSIC.test(text)
17
18const INTENT = [
19  'You read one message typed to a coding assistant and decide whether it asks to control music playback on the computer.',
20  'Answer with one line of JSON and nothing else, one of:',
21  '{"action":"play","query":"...","long":true}',
22  '{"action":"toggle"}',
23  '{"action":"next"}',
24  '{"action":"similar"}',
25  '{"action":"stop"}',
26  '{"action":"volume","delta":10}',
27  '{"action":"none"}',
28  'play: the user wants music played now. query: YouTube search words, in English for a genre or mood, as given for a named artist or title.',
29  'long: true for background or mood music (ambient, lofi, focus, a genre), false for one named track.',
30  'toggle: pause or resume the music. next: skip to another one. similar: more songs like the one playing. stop: stop, cut or turn off the music.',
31  'volume: louder (delta 10, or 20 for much louder) or quieter (delta -10, or -20).',
32  'Only a direct, short command to the music player counts. A question, a remark, a request to change software or code, or a description of how music should behave is none, even when it names volume or music.',
33  'none: anything else, including talk about music, code that plays audio, and requests aimed at Renoise, play7, keys7, a piano, a pattern or a melody.',
34].join('\n')
35
36type Intent =
37  | { action: 'play'; query: string; long: boolean }
38  | { action: 'toggle' }
39  | { action: 'next' }
40  | { action: 'similar' }
41  | { action: 'stop' }
42  | { action: 'volume'; delta: number }
43  | { action: 'none' }
44
45// A song lasts two to twenty minutes; above is an album or a mix. A mood
46// asked in words prefers five minutes and more.
47const MIN_SECONDS = 120
48const LONG_SECONDS = 300
49const MAX_SECONDS = 1200
50const SEARCH_SIZE = 8
51const PANE = 'jukebox7'
52const POLL_MS = 5_000
53// The phone's presses are read this often.
54const REMOTE_MS = 1_000
55
56// One click, a radio: a random artist of the genre, a random song of theirs;
57// next and the end of the song roll again. Seeded from the author's own listening.
58export const GENRES: { key: string; label: string; artists: string[]; discover?: string[] }[] = [
59  {
60    key: '1',
61    label: 'ambient',
62    artists: ['Boards of Canada', 'Loscil', 'Helios', 'Múm', 'Kenji Kawai Ghost in the Shell', 'Rafael Anton Irisarri', 'Ulver Perdition City', 'Brian Eno', 'bxnwxghxrn', 'Deru 1979'], discover: ['Grouper', 'Tim Hecker', 'William Basinski', 'Stars of the Lid', 'Hiroshi Yoshimura', 'Chihei Hatakeyama', 'Biosphere', 'Huerco S.', 'Kali Malone', 'Sarah Davachi', '36 ambient', 'Rafael Anton Irisarri Agitas'],
63  },
64  { key: '2', label: 'alt 90s', artists: ['Radiohead', 'The Clash', 'Pulp', 'Smashing Pumpkins', 'Pixies Doolittle', 'Pavement', 'Supergrass', 'Tool', 'Nine Inch Nails', 'Rage Against the Machine', 'My Bloody Valentine', 'Queens of the Stone Age', 'Slint', 'Beck'], discover: ['Unwound', 'Polvo', 'Archers of Loaf', 'Sebadoh', 'Hum', 'Failure', 'Swirlies', 'Dinosaur Jr', 'MJ Lenderman', 'Wednesday band', 'Horse Jumper of Love'] },
65  {
66    key: '3',
67    label: 'metal',
68    artists: ['Dissection', 'Cradle of Filth Dusk and Her Embrace', 'Emperor', 'Dimmu Borgir Enthrone Darkness Triumphant', 'Agalloch', 'Ulver Bergtatt', 'Arcturus', 'Blut Aus Nord', 'Wolves in the Throne Room', 'Opeth', 'Gojira', 'Children of Bodom', 'In Flames', 'Therion', 'Diabolical Masquerade', 'Anorexia Nervosa', 'Summoning', 'CLANN', 'Drabikowski Batushka', 'Limbonic Art', 'Obtained Enslavement', 'Peste Noire'], discover: ['Deathspell Omega', 'Mgła', 'Panopticon', 'Alcest', 'Oranssi Pazuzu', 'Wiegedood', 'Cult of Luna', 'Russian Circles', 'Igorrr', 'Sumac', 'Misþyrming', 'Svartidauði', 'Imperial Triumphant', 'Erang dungeon synth', 'Old Tower'],
69  },
70  { key: '4', label: 'synth & electro', artists: ['Fixions', 'Mega Drive', 'Danger 11h30', 'Perturbator', 'Carpenter Brut', 'Dan Terminus', 'Gost', 'Dance With the Dead', 'Master Boot Record', 'Crystal Castles', 'The Knife Silent Shout', 'Grimes Visions', 'Justice', 'Röyksopp', 'Atari Teenage Riot', 'Waveshaper', 'RWD NRTHNDR', 'Lorn Ask the Dust', 'SurgeryHead', 'Baldocaster', 'The Toxic Avenger', 'Scattle'], discover: ['Volkor X', 'Daniel Deluxe', 'Magic Sword', 'Irving Force', 'Hollywood Burns', 'Lueur Verte', 'Gunship', 'Lazerhawk', 'Boy Harsher', 'Drab Majesty', 'Kavinsky Reborn', 'Perturbator Lustful Sacraments'] },
71  { key: '5', label: 'idm', artists: ['Aphex Twin', 'Plaid', 'Boards of Canada', 'Squarepusher', 'Venetian Snares', 'Autechre', 'Clark', 'Wisp The Shimmering Hour', 'Arovane', 'Kettel', 'Jega', 'Bogdan Raczynski', 'Ceephax Acid Crew', 'The Future Sound of London', 'Tujiko Noriko', 'Plone', 'Ochre', 'The Tuss', 'Daed', 'EOD Questionmarks', 'Acrnym', 'Koolmorf Widesen', 'exandroid', 'James Shinra', 'Stazma', 'µ-Ziq', 'ID:null', 'Gareth Clarke', 'Hitori Tori'], discover: ['Richard Devine', 'Proem', 'Lusine', 'Funckarma', 'Sewerslvt', 'Machine Girl', 'Rival Consoles', 'Max Cooper', 'Jlin', 'Lanark Artefax', 'Loraine James', 'Bogdan Raczynski Rave Till You Cry'] },
72  {
73    key: '6',
74    label: 'indie rock',
75    artists: ['Pixies', 'Modest Mouse', 'Blonde Redhead', 'Eels', 'Sparklehorse', 'Elliott Smith', 'Grandaddy', 'Built to Spill', 'The Unicorns', 'Sufjan Stevens', 'MGMT', 'Ratatat', 'Perfume Genius', 'The Notwist', 'Beach House', 'Weezer', 'Les Savy Fav', 'Wolf Parade', 'The Shins', 'Animal Collective', 'Arcade Fire', 'Joanna Newsom', 'Broadcast', 'Syd Matters', 'The Walkmen', 'The Strokes', 'Metric', 'The Flaming Lips', 'Clues Alden Penner', 'Jason Lytle', 'Ugly Casanova', 'CocoRosie', 'Soap&Skin', 'Xiu Xiu', 'Lykke Li', 'Candy Claws', 'Surfer Blood'], discover: ['Car Seat Headrest', 'Alex G', 'Duster Stratosphere', 'Pinegrove', 'Big Thief', 'Low Sparhawk slowcore', 'Sun Kil Moon', 'Unknown Mortal Orchestra', 'Black Country New Road', 'Weyes Blood', 'Alvvays', 'Caroline Polachek', 'Zola Jesus', 'Chelsea Wolfe', 'Ethel Cain', 'Fever Ray'],
76  },
77  {
78    key: '7',
79    label: 'video games',
80    artists: [
81      'NieR Automata soundtrack Keiichi Okabe', 'Jeremy Soule Skyrim', 'Jeremy Soule Morrowind', 'Jeremy Soule Oblivion',
82      'Akira Yamaoka Silent Hill', 'Deus Ex soundtrack', 'Christopher Larkin Hollow Knight', 'C418 Minecraft', 'Cyberpunk 2077 soundtrack',
83      'Darren Korb Bastion', 'Darren Korb Transistor', 'Lena Raine Celeste', 'Mark Morgan Fallout', 'Mark Morgan Planescape Torment',
84      'FlybyNo Endless Space', 'FlybyNo Endless Legend', 'Arnaud Roy Humankind', 'Andrew Prahlow Outer Wilds', 'Furi soundtrack', 'Waveshaper Masters of Light',
85    ], discover: ['Disasterpeace Fez', 'Darren Korb Hades', 'Austin Wintory Journey', 'Ben Prunty FTL', 'Toby Fox Undertale', 'Mick Gordon Doom', 'Hideki Naganuma', 'Christopher Larkin Silksong', 'Darren Korb Hades II', 'Disasterpeace Hyper Light Drifter', 'Lena Raine Chicory', 'Toby Fox Deltarune', 'Mick Gordon Atomic Heart'],
86  },
87  {
88    key: '8',
89    label: 'film & modern classical',
90    artists: ['Joe Hisaishi', 'Philip Glass', 'Max Richter', 'Kenji Kawai Ghost in the Shell', 'Ryuichi Sakamoto', 'Jóhann Jóhannsson', 'Hans Zimmer The Thin Red Line', 'Clint Mansell The Fountain', 'Trevor Jones The Last of the Mohicans', 'Elliot Goldenthal Heat', 'Thomas Newman American Beauty', 'John Williams', 'Antonín Dvořák New World Symphony'],
91    discover: ['Ólafur Arnalds', 'Nils Frahm', 'Arvo Pärt', 'Hildur Guðnadóttir', 'Steve Reich', 'Michael Nyman', 'Hania Rani', 'A Winged Victory for the Sullen', 'Hildur Guðnadóttir Chernobyl', 'Kali Malone Living Torch', 'Caterina Barbieri'],
92  },
93  {
94    key: '9',
95    label: 'hip-hop & abstract',
96    artists: ['Wu-Tang Clan', 'Jedi Mind Tricks', 'Kanye West', 'cLOUDDEAD', 'Sage Francis', 'Danger Mouse', 'Why?', 'Nujabes', 'Prefuse 73', 'DJ Shadow', 'Bonobo', 'Themselves', 'Odd Nosdam', 'Alias Anticon', 'Sole Anticon', 'Son Lux', 'Beastie Boys', 'Cypress Hill', 'Stupeflip', 'Wax Tailor', 'Outkast', 'Eminem', 'Dr. Dre', 'Passage Anticon', 'Death Grips'],
97    discover: ['MF DOOM', 'Cannibal Ox', 'Aesop Rock', 'Company Flow', 'Armand Hammer', 'billy woods', 'clipping.', 'Deltron 3030', 'Madlib', 'J Dilla', 'Flying Lotus', 'Knxwledge', 'JPEGMAFIA', 'Danny Brown', 'ShrapKnel', 'Backxwash'],
98  },
99  {
100    key: '0',
101    label: 'post-rock',
102    artists: ['Godspeed You! Black Emperor', 'Explosions in the Sky', 'Mono', 'Isis', 'Red Sparowes', 'Mogwai', 'Anathema', 'Sigur Rós', 'This Will Destroy You', 'The Mars Volta'],
103    discover: ['Caspian', 'If These Trees Could Talk', 'Pelican', 'Do Make Say Think', 'Tortoise', 'Hammock', 'Lost in Kiev', 'God Is an Astronaut', 'Sumac', 'Year of No Light', 'Bell Witch'],
104  },
105]
106
107// A genre's artists, or else the station of the avatar on duty, which
108// avatar7 publishes from the persona's own persona.json.
109async function poolOf($: Engine, label: string): Promise<{ name: string; artists: string[] }> {
110  const genre = GENRES.find(g => g.label === label)
111  if (genre !== undefined) return { name: label, artists: genre.artists }
112  const { value } = await $.state.get(onStation)
113  return { name: value?.name || label, artists: value?.artists ?? [] }
114}
115// Artists picked for a genre beyond the listener's own: one pick in three
116// comes from there, and its title wears a ✦.
117const discoveries = (label: string): string[] => GENRES.find(g => g.label === label)?.discover ?? []
118const DISCOVER_ODDS = 1 / 3
119export const DISCOVERED = '✦ '
120const onDuty = { plugin: 'avatar7', key: 'avatar' } as const
121const tint = { plugin: 'avatar7', key: 'color' } as const
122const onStation = { plugin: 'avatar7', key: 'station' } as const
123// Without an avatar on duty the pane keeps the terminal's phosphor green.
124const PHOSPHOR = '#00ff9c'
125const BLACK = '#000000'
126const BAR = 12
127
128const anyOf = <T,>(xs: T[]): T | undefined => xs[Math.floor(Math.random() * xs.length)]
129export const isSong = (t: Track): boolean => t.seconds !== null && t.seconds >= MIN_SECONDS && t.seconds <= MAX_SECONDS
130
131const IDLE: Player = { tracks: [], index: 0, pgid: null, isPlaying: false, genre: null, startedAt: null, pausedAt: null }
132const player = atom({ plugin: 'jukebox7', key: 'player' } as const, IDLE)
133// The last search asked for a pick (/music find, or the phone): what was
134// asked and what YouTube found, in its order. Nothing plays until a pick.
135const found = atom({ plugin: 'jukebox7', key: 'found' } as const, { query: '', tracks: [] } as Found)
136let isFinding = false
137// The pane's search: its field and the results, shown after `f` until `f`
138// again. Each result answers a letter the pane leaves free (digits are the
139// stations', the rest its controls').
140const searching = atom({ plugin: 'jukebox7', key: 'searching' } as const, false)
141export const PICK_KEYS = ['q', 'w', 'e', 't', 'y', 'i', 'o', 'l']
142const PANE_ROWS = 6
143// The pane grows by the search's rows (the field, a result each, or one
144// line while it searches), so the rain is not all that gives way.
145const paneRows = (isSearching: boolean, results: number): number => PANE_ROWS + (isSearching ? 1 + Math.max(1, results) : 0)
146// Percent of VLC's 100 %, kept apart from the player so a stop keeps it.
147const volume = atom({ plugin: 'jukebox7', key: 'volume' } as const, 70)
148const VOLUME_STEP = 10
149const VOLUME_MAX = 125
150export const clampVolume = (v: number): number => Math.min(VOLUME_MAX, Math.max(0, Math.round(v)))
151
152export const parseIntent = (text: string): Intent => {
153  const json = /\{[^}]*\}/.exec(text)?.[0]
154  if (json === undefined) return { action: 'none' }
155  try {
156    const v = JSON.parse(json) as Record<string, unknown>
157    if (v.action === 'play' && typeof v.query === 'string' && v.query.trim() !== '') {
158      return { action: 'play', query: v.query.trim(), long: v.long === true }
159    }
160    if (v.action === 'toggle' || v.action === 'next' || v.action === 'similar' || v.action === 'stop') return { action: v.action }
161    if (v.action === 'volume' && typeof v.delta === 'number' && v.delta !== 0) return { action: 'volume', delta: v.delta }
162  } catch {
163    // Not JSON after all: the prompt goes on to the session.
164  }
165  return { action: 'none' }
166}
167
168// yt-dlp prints one `id<TAB>title<TAB>duration` line per result; a live
169// stream has no duration (NA), which suits a backdrop.
170export const parseTracks = (stdout: string): Track[] =>
171  stdout
172    .split('\n')
173    .map(l => l.split('\t'))
174    .filter(([id]) => id !== undefined && /^[\w-]{11}$/.test(id))
175    .map(([id, title, d]) => ({
176      id: id as string,
177      title: (title ?? '').trim(),
178      seconds: d === undefined || !/^\d+(\.\d+)?$/.test(d.trim()) ? null : Math.round(Number(d)),
179    }))
180
181// The playlist starts on the first fitting result; next walks on from there.
182export const pickTrack = (stdout: string, long: boolean): Track | undefined => {
183  const tracks = parseTracks(stdout)
184  if (!long) return tracks[0]
185  const songs = tracks.filter(isSong)
186  return songs.find(t => (t.seconds ?? 0) >= LONG_SECONDS) ?? songs[0] ?? tracks[0]
187}
188
189const search = (query: string): string[] => [
190  'yt-dlp',
191  '--flat-playlist',
192  '--no-warnings',
193  '--print',
194  '%(id)s\t%(title)s\t%(duration)s',
195  `ytsearch${SEARCH_SIZE}:${query}`,
196]
197
198// A station pick searches YouTube Music's songs tab: only tracks, never an
199// interview, a gameplay video or a full OST, at the cost of the durations
200// (the flat listing gives none). Plain YouTube, length-filtered, is the
201// fallback when it finds nothing.
202export const musicSearch = (query: string): string[] => [
203  'yt-dlp',
204  '--flat-playlist',
205  '--no-warnings',
206  '--playlist-end',
207  String(SEARCH_SIZE),
208  '--print',
209  '%(id)s\t%(title)s\t%(duration)s',
210  `https://music.youtube.com/search?q=${encodeURIComponent(query)}#songs`,
211]
212
213// YouTube Music's search lists no duration (NA): the one playing is asked
214// for its own, so the progress bar has something to fill.
215export const durationArgv = (id: string): string[] => [
216  'yt-dlp',
217  '--no-warnings',
218  '--skip-download',
219  '--print',
220  '%(duration)s',
221  `https://www.youtube.com/watch?v=${id}`,
222]
223
224// YouTube Music's radio from a song: its first entry is the song itself.
225// The radio lingers on the same artist, so other artists come first; covers,
226// tributes and hour-long loops (the drift of a niche radio) are dropped.
227export const radioArgv = (id: string): string[] => [
228  'yt-dlp',
229  '--flat-playlist',
230  '--no-warnings',
231  '--playlist-end',
232  '25',
233  '--print',
234  '%(id)s\t%(title)s\t%(duration)s\t%(channel)s',
235  `https://music.youtube.com/watch?v=${id}&list=RDAMVM${id}`,
236]
237const COVER = /\b(cover|tribute|relaxing|reimagined|orchestral version|piano version|8.?bit|lofi version|1 hour|hours?\b|extended|epic music|fantasy music|sleep|study)\b/i
238export const parseRadio = (stdout: string): Track[] => {
239  const rows = stdout
240    .split('\n')
241    .map(l => l.split('\t'))
242    .filter(([id]) => id !== undefined && /^[\w-]{11}$/.test(id))
243    .map(([id, title, d, channel]) => ({
244      id: id as string,
245      title: (title ?? '').trim(),
246      seconds: d === undefined || !/^\d+(\.\d+)?$/.test(d.trim()) ? null : Math.round(Number(d)),
247      artist: (channel ?? '').replace(/ - Topic$/, '').trim(),
248    }))
249  const [self, ...rest] = rows
250  const kept = rest.filter(r => !COVER.test(r.title) && !COVER.test(r.artist) && (r.seconds === null || isSong(r)))
251  const others = kept.filter(r => r.artist !== self?.artist)
252  const same = kept.filter(r => r.artist === self?.artist)
253  return [...others, ...same].map(r => ({ id: r.id, title: r.artist === '' || r.artist === 'NA' ? r.title : `${r.artist} - ${r.title}`, seconds: r.seconds }))
254}
255
256// Audio only, no window: yt-dlp streams the best audio into Windows' VLC
257// with its dummy interface (WSLg's PulseAudio stalls after a sleep, VLC
258// plays on the Windows side and takes no focus). setsid puts the WSL side in
259// a process group of its own, which stop ends (pause asks VLC itself); vlc://quit
260// ends it with the stream; the id arrives as $1, checked against
261// /^[\w-]{11}$/ before.
262const VLC = '/mnt/c/Program Files/VideoLAN/VLC/vlc.exe'
263const TAG = '--meta-title=jukebox7'
264// The volume goes through VLC's HTTP interface, bound to Windows' loopback:
265// curl.exe runs there, where WSL's own localhost does not reach. The
266// password only satisfies VLC, which refuses the interface without one.
267const HTTP_PORT = 18797
268const HTTP_PASSWORD = 'jukebox7'
269const HTTP = `--extraintf http --http-host 127.0.0.1 --http-port ${HTTP_PORT} --http-password ${HTTP_PASSWORD}`
270const PIPE = `${REMOTE}yt-dlp -q --no-warnings -f bestaudio -o - "https://www.youtube.com/watch?v=$1" | "${VLC}" --intf dummy --dummy-quiet --play-and-exit --no-video -q ${TAG} ${HTTP} - vlc://quit`
271export const startArgv = (id: string): string[] => [
272  'bash',
273  '-c',
274  `setsid bash -c '${PIPE}' _ "$1" </dev/null >/dev/null 2>&1 & echo $!`,
275  '_',
276  id,
277]
278// Killing the WSL side leaves vlc.exe running: it goes by its tag, so a VLC
279// the user opened themselves is never touched. A VLC whose WSL side died
280// with its window shows no command line any more, and still holds the port
281// beside the next one (Windows lets both listen, the controls reach one of
282// them). Windows refuses to end that one even to its own user, but it still
283// obeys its interface: pl_stop ends it (--play-and-exit), once per listener,
284// each request reaching one. The port's owner goes last, where allowed.
285const POWERSHELL = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
286const BASIC = btoa(`:${HTTP_PASSWORD}`)
287export const killVlcArgv = [
288  POWERSHELL,
289  '-NoProfile',
290  '-Command',
291  `1..3 | ForEach-Object { try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 1 -Headers @{ Authorization = 'Basic ${BASIC}' } 'http://127.0.0.1:${HTTP_PORT}/requests/status.xml?command=pl_stop' | Out-Null } catch {} }; ` +
292    `Get-CimInstance Win32_Process -Filter "Name='vlc.exe'" | Where-Object { $_.CommandLine -like '*${TAG}*' } | Invoke-CimMethod -MethodName Terminate | Out-Null; ` +
293    `Get-NetTCPConnection -LocalPort ${HTTP_PORT} -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue } | Where-Object Name -eq 'vlc' | Stop-Process -Force -ErrorAction SilentlyContinue`,
294]
295// VLC counts 256 for 100 %. A fresh VLC starts at whatever Windows kept for
296// it (--mmdevice-volume is ignored), so each start sets the level again,
297// retrying while the interface is not up yet.
298const CURL = '/mnt/c/Windows/System32/curl.exe'
299export const volumeArgv = (percent: number, retry = false): string[] => [
300  CURL,
301  '-s',
302  '-o',
303  'NUL',
304  '-u',
305  `:${HTTP_PASSWORD}`,
306  ...(retry ? ['--retry', '10', '--retry-connrefused', '--retry-delay', '1'] : ['--max-time', '2']),
307  `http://127.0.0.1:${HTTP_PORT}/requests/status.xml?command=volume&val=${Math.round((clampVolume(percent) * 256) / 100)}`,
308]
309// Pause and resume through the same interface: instant, where stopping the
310// WSL pipeline only starved VLC after its buffer ran out, seconds later or
311// not at all. force* are idempotent, so a missed click cannot invert them.
312// On the phone (music.json holds a song), pause flips its flag; here, VLC's.
313export const pauseArgv = (isPaused: boolean): string[] => [
314  'sh',
315  '-c',
316  pauseScript(isPaused),
317  '_',
318  CURL,
319  '-sf',
320  '-o',
321  'NUL',
322  '-u',
323  `:${HTTP_PASSWORD}`,
324  '--max-time',
325  '2',
326  `http://127.0.0.1:${HTTP_PORT}/requests/status.xml?command=${isPaused ? 'pl_forcepause' : 'pl_forceresume'}`,
327]
328
329// How many rows a wrapped line of plain Buttons takes: each draws as
330// `k: label`, three columns past its label, with `gap` between them.
331export const buttonRows = (labels: string[], columns: number, gap = 2): number => {
332  let rows = 1
333  let used = 0
334  for (const label of labels) {
335    const w = label.length + 3
336    if (used > 0 && used + gap + w > columns) {
337      rows += 1
338      used = w
339    } else {
340      used += (used > 0 ? gap : 0) + w
341    }
342  }
343  return rows
344}
345
346// While the avatar speaks, the music drops to this share of its level.
347export const DUCK = 0.7
348export const duckArgv = (level: number, isVoicing: boolean): string[] => volumeArgv(isVoicing ? level * DUCK : level)
349const detached = (argv: string[]): string[] => ['bash', '-c', 'setsid "$0" "$@" </dev/null >/dev/null 2>&1 &', ...argv]
350export const detachedKillVlcArgv = ['bash', '-c', 'setsid "$0" "$@" </dev/null >/dev/null 2>&1 &', ...killVlcArgv]
351const signal = (sig: 'STOP' | 'CONT' | 'TERM', pgid: number): string[] => ['kill', `-${sig}`, '--', `-${pgid}`]
352
353const show = (p: Player) => {
354  const t = p.tracks[p.index]
355  return t === undefined ? undefined : `music${p.isPlaying ? '' : ' (paused)'}: ${t.title}`
356}
357
358async function halt($: Engine, pgid: number | null) {
359  // A stopped group ignores TERM until it runs again.
360  if (pgid !== null) {
361    await $.process.run(signal('CONT', pgid))
362    await $.process.run(signal('TERM', pgid))
363  }
364  await $.process.run(killVlcArgv)
365}
366
367// Fills in the duration of the track playing, if it still plays when the
368// answer comes; the play never waits on it.
369async function fillDuration($: Engine, id: string): Promise<void> {
370  try {
371    const r = await $.process.run(durationArgv(id), { timeoutMs: 20_000 })
372    const [found] = parseTracks(`${id}\t\t${r.stdout.trim()}`)
373    if (r.exitCode !== 0 || found?.seconds == null) return
374    const seconds = found.seconds
375    await update($, player, p =>
376      p.tracks[p.index]?.id === id ? { ...p, tracks: p.tracks.map((t, i) => (i === p.index ? { ...t, seconds } : t)) } : p,
377    )
378  } catch {
379    // No duration (yt-dlp failed, the session ended): the bar stays a clock.
380  }
381}
382
383// What the persona on duty hears when a song starts: the title, plain, and
384// the cue to introduce it in its own manner.
385export const introEvent = (title: string): string =>
386  `the jukebox starts "${title.replace(DISCOVERED, '')}"; introduce it in your own manner, the way a radio host would`
387
388async function playAt($: Engine, tracks: Track[], index: number, genre: string | null = null): Promise<Track | undefined> {
389  // Halted even when nothing plays from here: an orphan VLC on the port
390  // would take the controls of the new one. One jukebox per machine.
391  await halt($, (await read($, player)).pgid)
392  const track = tracks[index]
393  if (track === undefined) {
394    await update($, player, () => IDLE)
395    $.ui.status(undefined)
396    return undefined
397  }
398  const started = await $.process.run(startArgv(track.id))
399  const pgid = Number(started.stdout.trim())
400  const p: Player = {
401    tracks,
402    index,
403    pgid: Number.isInteger(pgid) && pgid > 1 ? pgid : null,
404    isPlaying: true,
405    genre,
406    startedAt: await $.clock.now(),
407    pausedAt: null,
408  }
409  await update($, player, () => p)
410  // Every start goes through here: asked, from a genre, or the next one when
411  // a song ends; avatar7, if loaded, speaks over the intro.
412  await $.state.set({ plugin: 'jukebox7', key: 'say' }, { mood: 'watch', event: introEvent(track.title), at: Date.now() } satisfies Say)
413  if (p.pgid !== null) await $.process.run(detached(volumeArgv(await read($, volume), true)))
414  if (track.seconds === null) void fillDuration($, track.id)
415  $.ui.status(show(p))
416  void $.ui.open({ id: PANE, title: 'jukebox7', rows: 6 })
417  return track
418}
419
420// One change of song at a time: a search takes up to 40 s, and the poll, a
421// button and a command would each start their own song meanwhile.
422let isMoving = false
423async function once($: Engine, change: () => Promise<Track | undefined>): Promise<Track | undefined> {
424  if (isMoving) {
425    $.ui.toast('music: a song is already on its way')
426    return undefined
427  }
428  isMoving = true
429  try {
430    return await change()
431  } finally {
432    isMoving = false
433  }
434}
435
436// Past the last result the list ends (playAt goes idle): no wrapping back to
437// the first, which would loop for ever over tracks that cannot play.
438async function skip($: Engine, by: number): Promise<Track | undefined> {
439  const p = await read($, player)
440  if (typeof p.genre === 'string') return playGenre($, p.genre)
441  return playAt($, p.tracks, p.index + by)
442}
443
444async function toggle($: Engine): Promise<Player | undefined> {
445  const p = await read($, player)
446  if (p.pgid === null) return p
447  // VLC not listening (still starting, gone): the state stays as it is, so
448  // the poll keeps watching a song that still plays.
449  const sent = await $.process.run(pauseArgv(p.isPlaying))
450  if (sent.exitCode !== 0) {
451    $.ui.toast('music: VLC did not answer, try again')
452    return undefined
453  }
454  const now = await $.clock.now()
455  const q: Player = p.isPlaying
456    ? { ...p, isPlaying: false, pausedAt: now }
457    : { ...p, isPlaying: true, pausedAt: null, startedAt: (p.startedAt ?? now) + (now - (p.pausedAt ?? now)) }
458  await update($, player, () => q)
459  // A voice that started or ended while paused: the level follows it again.
460  if (q.isPlaying) await $.process.run(detached(duckArgv(await read($, volume), await isAvatarVoicing($))))
461  $.ui.status(show(q))
462  return q
463}
464
465async function isAvatarVoicing($: Engine): Promise<boolean> {
466  return (await $.state.get({ plugin: 'avatar7', key: 'isVoicing' })).value === true
467}
468
469async function louder($: Engine, delta: number): Promise<number> {
470  const v = clampVolume((await read($, volume)) + delta)
471  await update($, volume, () => v)
472  if ((await read($, player)).pgid !== null) await $.process.run(duckArgv(v, await isAvatarVoicing($)))
473  return v
474}
475
476async function stop($: Engine) {
477  const p = await read($, player)
478  await halt($, p.pgid)
479  await update($, player, () => IDLE)
480  $.ui.status(undefined)
481  await $.ui.close({ id: PANE })
482}
483
484async function play($: Engine, query: string, long: boolean): Promise<Track | undefined> {
485  const found = await $.process.run(search(query), { timeoutMs: 20_000 })
486  if (found.exitCode !== 0) return undefined
487  const first = pickTrack(found.stdout, long)
488  if (first === undefined) return undefined
489  // The track asked for plays whatever its length; next walks songs only
490  // (no hour-long mix, no live stream that never ends).
491  const rest = parseTracks(found.stdout).filter(t => t.id !== first.id && isSong(t))
492  return playAt($, [first, ...rest], 0)
493}
494
495// Lists what YouTube finds for a search, and keeps it for a pick; undefined
496// when yt-dlp failed. One search at a time.
497async function find($: Engine, query: string): Promise<Track[] | undefined> {
498  isFinding = true
499  try {
500    const r = await $.process.run(search(query), { timeoutMs: 20_000 })
501    if (r.exitCode !== 0) return undefined
502    const tracks = parseTracks(r.stdout)
503    await update($, found, () => ({ query, tracks }))
504    return tracks
505  } finally {
506    isFinding = false
507  }
508}
509
510// The result at `index` (from 0) plays whatever its length; next walks on
511// through the rest of the search, as after /music <search>.
512async function pick($: Engine, index: number): Promise<Track | undefined> {
513  const f = await read($, found)
514  if (f.tracks[index] === undefined) return undefined
515  return playAt($, f.tracks, index)
516}
517
518// The results as /music find prints them: numbered from 1, with a length
519// when YouTube gave one.
520export const listing = (tracks: Track[]): string =>
521  tracks.map((t, i) => `${i + 1}. ${t.title}${t.seconds === null ? '' : ` (${clock(t.seconds)})`}`).join('\n')
522
523async function playGenre($: Engine, label: string): Promise<Track | undefined> {
524  const fresh = discoveries(label)
525  const isNew = fresh.length > 0 && Math.random() < DISCOVER_ODDS
526  const pool = await poolOf($, label)
527  const artist = anyOf(isNew ? fresh : pool.artists)
528  if (artist === undefined) return undefined
529  $.ui.status(`music: looking for ${pool.name}, ${artist}…`)
530  const was = (await read($, player)).tracks
531  const music = await $.process.run(musicSearch(artist), { timeoutMs: 20_000 })
532  let songs = music.exitCode === 0 ? parseTracks(music.stdout).filter(t => t.seconds === null || isSong(t)) : []
533  // Its titles carry no artist; the query names it.
534  songs = songs.map(t => ({ ...t, title: `${isNew ? DISCOVERED : ''}${artist} - ${t.title}` }))
535  if (songs.length === 0) {
536    const found = await $.process.run(search(artist), { timeoutMs: 20_000 })
537    songs = found.exitCode === 0 ? parseTracks(found.stdout).filter(isSong) : []
538    if (isNew) songs = songs.map(t => ({ ...t, title: `${DISCOVERED}${t.title}` }))
539  }
540  const unheard = songs.filter(t => !was.some(w => w.id === t.id))
541  const track = anyOf(unheard.length > 0 ? unheard : songs)
542  if (track === undefined) {
543    $.ui.status(undefined)
544    $.ui.toast(`music: nothing found for ${artist}`)
545    await $.state.set({ plugin: 'jukebox7', key: 'say' }, { mood: 'error', event: `the jukebox found nothing to play for ${artist}`, at: Date.now() } satisfies Say)
546    return undefined
547  }
548  $.ui.toast(`music now playing: ${track.title}`)
549  return playAt($, [track], 0, label)
550}
551
552// More like the song playing: its YouTube Music radio, as a list next walks.
553async function playSimilar($: Engine): Promise<Track | undefined> {
554  const p = await read($, player)
555  const now = p.tracks[p.index]
556  if (now === undefined) return undefined
557  $.ui.status(`music: looking for songs like ${now.title}…`)
558  const found = await $.process.run(radioArgv(now.id), { timeoutMs: 20_000 })
559  const tracks = found.exitCode === 0 ? parseRadio(found.stdout) : []
560  if (tracks.length === 0) {
561    $.ui.status(show(p))
562    $.ui.toast('music: no radio for this one')
563    return undefined
564  }
565  $.ui.toast(`music, like ${now.title}: ${tracks[0]?.title}`)
566  return playAt($, tracks, 0, null)
567}
568
569const clock = (s: number): string => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`
570
571// `▰▰▰▱▱▱ 03:12 / 07:00`, or the elapsed time alone when YouTube gave no length.
572export const progress = (p: Player, now: number, seconds: number | null): { done: string; left: string; time: string } => {
573  const elapsed = p.startedAt === null ? 0 : Math.max(0, ((p.pausedAt ?? now) - p.startedAt) / 1000)
574  if (seconds === null || seconds <= 0) return { done: '', left: '', time: clock(elapsed) }
575  const filled = Math.min(BAR, Math.round((elapsed / seconds) * BAR))
576  return { done: '▰'.repeat(filled), left: '▱'.repeat(BAR - filled), time: `${clock(Math.min(elapsed, seconds))} / ${clock(seconds)}` }
577}
578
579// Ghost in the Shell's green code rain under the player: half-width katakana
580// and digits falling per column, moving only while a song plays.
581const GLYPHS = 'ヲァィゥェォャュョッアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789'
582const TRAIL = 6
583const RAIN_MS = 200
584let frame = 0
585const hash = (n: number): number => {
586  let h = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b)
587  h ^= h >>> 13
588  h = Math.imul(h, 0xc2b2ae35)
589  return (h ^ (h >>> 16)) >>> 0
590}
591
592// One row is a list of runs: 0 blank, 1 trail (dim), 2 head (bright).
593export const rain = (columns: number, rows: number, tick: number): { level: 0 | 1 | 2; text: string }[][] =>
594  Array.from({ length: rows }, (_, r) => {
595    const runs: { level: 0 | 1 | 2; text: string }[] = []
596    for (let c = 0; c < columns; c++) {
597      const span = rows + TRAIL + (hash(c * 7 + 1) % rows || 1)
598      const speed = 1 + (hash(c * 13 + 5) % 2)
599      const head = (Math.floor((tick * speed) / 2) + hash(c * 29 + 3)) % span
600      const behind = head - r
601      const level: 0 | 1 | 2 = c % 2 === 1 || behind < 0 || behind > TRAIL ? 0 : behind === 0 ? 2 : 1
602      const ch = level === 0 ? ' ' : (GLYPHS[hash(c * 131 + r * 17 + Math.floor(tick / 3)) % GLYPHS.length] ?? ' ')
603      const last = runs[runs.length - 1]
604      if (last !== undefined && last.level === level) last.text += ch
605      else runs.push({ level, text: ch })
606    }
607    return runs
608  })
609
610export const register: Register = on => {
611  // A daemon's background session (a spare kept warm, a `claude --bg`)
612  // inherits the plugin dirs but nobody listens to it: no /music, no poll,
613  // no prompt turned into a song.
614  let isBackground = false
615
616  // avatar7 speaking: the music steps back, then returns to its level. The
617  // curl is detached, so the voice never waits on VLC.
618  on('state.set', { plugin: 'avatar7', key: 'isVoicing' }, async ($, e, next) => {
619    const done = await next(e)
620    const p = await read($, player)
621    if (p.pgid !== null && p.isPlaying) await $.process.run(detached(duckArgv(await read($, volume), e.value === true)))
622    return done
623  })
624
625  on('session.start', async ($, e, next) => {
626    const kind = await $.process.run(['sh', '-c', 'printf %s "$CLAUDE_CODE_SESSION_KIND"'])
627    isBackground = kind.stdout === 'bg'
628    if (isBackground) return next(e)
629
630    await $.command.register({
631      name: 'music',
632      description: 'Play music from YouTube: /music <search>, /music find <search> then /music pick <n>, /music pause, /music next, /music similar, /music stop, /music vol [+|-]<n>, /music alone for what plays',
633    })
634
635    // A pipeline that ended by itself (the track is over) moves on to the
636    // next result; one that cannot be found any more is forgotten.
637    $.clock.every(POLL_MS, () => {
638      void (async () => {
639        const p = await read($, player)
640        if (p.pgid === null || !p.isPlaying || isMoving) return
641        const alive = await $.process.run(['kill', '-0', '--', `-${p.pgid}`])
642        if (alive.exitCode === 0) {
643          $.ui.invalidate('ui.render')
644          return
645        }
646        // Nothing to follow (the list ended, no network, no station): the
647        // jukebox goes idle rather than retry every tick.
648        if ((await once($, () => skip($, 1))) === undefined) {
649          await update($, player, () => IDLE)
650          $.ui.status(undefined)
651        }
652      })()
653    })
654
655    // The phone, through avatar7's relay: its presses for the jukebox, and
656    // what the jukebox shows it, written again only when it changed.
657    let shown = ''
658    // Where the voice was at the last tick: undefined until first seen.
659    let wasHeld: boolean | undefined
660    $.clock.every(REMOTE_MS, () => {
661      void (async () => {
662        // The relay just taken: a song playing here starts again from its
663        // beginning, and its start takes the remote branch (no seek through
664        // the relay yet). Given back, the phone's song ends by itself and the
665        // next one plays here.
666        const isHeld = (await $.process.run(heldArgv())).stdout.trim() === 'up'
667        const was = wasHeld
668        wasHeld = isHeld
669        if (isHeld && was === false && !isMoving) {
670          const p = await read($, player)
671          if (p.pgid !== null && p.isPlaying) await once($, () => playAt($, p.tracks, p.index, p.genre))
672        }
673        const out = (await $.process.run(drainArgv())).stdout
674        for (const line of out.split('\n')) {
675          const press = parseJukebox(line, GENRES.map(g => g.label))
676          if (press === undefined) continue
677          if (press.do === 'pause') await toggle($)
678          else if (press.do === 'stop') await stop($)
679          else if (press.do === 'next') await once($, () => skip($, 1))
680          else if (press.do === 'similar') await once($, () => playSimilar($))
681          else if (press.do === 'find') {
682            if (!isFinding) await find($, press.query)
683          } else if (press.do === 'pick') await once($, () => pick($, press.index))
684          else await once($, () => playGenre($, press.genre))
685          $.ui.invalidate('ui.render')
686        }
687        const p = await read($, player)
688        const now = p.pgid === null ? undefined : p.tracks[p.index]
689        const f = await read($, found)
690        const status = JSON.stringify({
691          title: now?.title ?? '', isPlaying: now !== undefined && p.isPlaying, genre: p.genre ?? '',
692          genres: GENRES.map(g => g.label), isMoving,
693          // The last search, for the phone to list and pick from.
694          query: f.query, found: f.tracks.map(t => ({ title: t.title, seconds: t.seconds })), isFinding,
695        })
696        if (status !== shown) {
697          await $.process.run(statusArgv(), { stdin: status })
698          shown = status
699        }
700      })()
701    })
702
703    $.clock.every(RAIN_MS, () => {
704      void (async () => {
705        if (!(await read($, player)).isPlaying) return
706        frame += 1
707        $.ui.invalidate('ui.render')
708      })()
709    })
710
711    // Unasked, the pane seats from 144 columns, under the avatar's; /music
712    // opens it at any width.
713    void $.ui.open({ id: PANE, title: 'jukebox7', rows: 6 })
714
715    return next(e)
716  })
717
718  // Quitting (or /clear, which loses $.state's pgid) must not orphan the
719  // detached pipeline. The end chain runs under one short bound and
720  // PowerShell starts slower than that: the group goes at once, the VLC
721  // kill is detached so it outlives the exit.
722  // A session that never played (another window, a background spare) leaves
723  // the VLC alone: it may be another session's song.
724  on('session.end', async ($, e, next) => {
725    const p = await read($, player)
726    if (p.pgid !== null) {
727      await $.process.run(signal('CONT', p.pgid))
728      await $.process.run(signal('TERM', p.pgid))
729      await $.process.run(detachedKillVlcArgv)
730    }
731    return next(e)
732  })
733
734  on('prompt.submit', async ($, e, next) => {
735    if (isBackground || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') || !isCandidate(e.text)) return next(e)
736
737    const r = await $.model.complete({
738      model: 'haiku',
739      system: INTENT,
740      prompt: e.text.slice(0, 500),
741      maxTokens: 60,
742      effort: 'low',
743      timeoutMs: 8_000,
744    })
745    if (r.usage !== undefined) {
746      await $.state.set({ plugin: 'jukebox7', key: 'modelUse' }, {
747        model: 'haiku', input: r.usage.input_tokens, output: r.usage.output_tokens, cacheRead: r.usage.cache_read_input_tokens ?? 0, at: Date.now(),
748      } satisfies ModelUse)
749    }
750    const intent = r.isAnswered ? parseIntent(r.text) : ({ action: 'none' } as const)
751
752    if (intent.action === 'none') return next(e)
753    // A song already on its way: no second search, and no false "nothing found".
754    const changes = intent.action === 'play' || intent.action === 'next' || intent.action === 'similar'
755    if (changes && isMoving) return { drop: 'jukebox7: a song is already on its way' }
756
757    if (intent.action !== 'play') {
758      // Nothing plays: a bare stop, pause or next was meant for the assistant.
759      if ((await read($, player)).pgid === null) return next(e)
760      if (intent.action === 'stop') {
761        await stop($)
762        $.ui.toast('music stopped')
763        return { drop: 'jukebox7: stopped' }
764      }
765      if (intent.action === 'volume') {
766        const v = await louder($, intent.delta)
767        $.ui.toast(`music volume ${v}%`)
768        return { drop: `jukebox7: volume ${v}%` }
769      }
770      if (intent.action === 'similar') {
771        const t = await once($, () => playSimilar($))
772        return { drop: `jukebox7: ${t === undefined ? 'no radio for this one' : `like it, ${t.title}`}` }
773      }
774      if (intent.action === 'next') {
775        const t = await once($, () => skip($, 1))
776        $.ui.toast(t === undefined ? 'music: end of the list' : `music now playing: ${t.title}`)
777        return { drop: `jukebox7: ${t === undefined ? 'end of the list' : `next, ${t.title}`}` }
778      }
779      const q = await toggle($)
780      if (q === undefined) return { drop: 'jukebox7: VLC did not answer' }
781      const said = q.isPlaying ? `resumed: ${q.tracks[q.index]?.title ?? ''}` : 'paused'
782      $.ui.toast(`music ${said}`)
783      return { drop: `jukebox7: ${said}` }
784    }
785
786    const track = await once($, () => play($, intent.query, intent.long))
787    if (track === undefined) return { drop: `jukebox7: nothing found on YouTube for "${intent.query}".` }
788    $.ui.toast(`music now playing: ${track.title}`)
789    return { drop: `jukebox7: playing "${track.title}" (https://youtu.be/${track.id})` }
790  })
791
792  on('command.run', { command: 'music' }, async ($, e) => {
793    const args = e.args.trim()
794    const isControl = args === '' || args === 'pause' || args === 'stop' || /^vol(?:ume)?\b/.test(args) || /^find\b/.test(args)
795    if (!isControl && isMoving) return { text: 'A song is already on its way.' }
796    const p = await read($, player)
797    const now = p.tracks[p.index]
798    if (args === '') {
799      void $.ui.open({ id: PANE, title: 'jukebox7', rows: 6 })
800      if (p.pgid === null || now === undefined) return { text: 'Nothing played from here yet: pick a genre in the pane.' }
801      return { text: `${p.isPlaying ? 'Playing' : 'Paused'}: ${now.title}` }
802    }
803
804    const vol = /^vol(?:ume)?(?:\s+([+-]?)(\d+))?$/.exec(args)
805    if (vol !== null) {
806      const [, sign, n] = vol
807      if (n === undefined) return { text: `Volume ${await read($, volume)}%.` }
808      const v = await louder($, sign === '' ? Number(n) - (await read($, volume)) : Number(`${sign}${n}`))
809      return { text: `Volume ${v}%.` }
810    }
811
812    if (args === 'similar' || args === 'like') {
813      const t = await once($, () => playSimilar($))
814      return { text: t === undefined ? 'No radio for this one.' : `Like it: ${t.title}` }
815    }
816
817    if (args === 'pause' || args === 'next' || args === 'stop') {
818      if (p.pgid === null) return { text: 'Nothing is playing from here.' }
819      if (args === 'stop') {
820        await stop($)
821        return { text: 'Stopped.' }
822      }
823      if (args === 'next') {
824        const t = await once($, () => skip($, 1))
825        return { text: t === undefined ? 'End of the list.' : `Next: ${t.title}` }
826      }
827      const q = await toggle($)
828      if (q === undefined) return { text: 'VLC did not answer; nothing changed.' }
829      return { text: q.isPlaying ? 'Resumed.' : 'Paused.' }
830    }
831
832    // /music find <search>: the results, numbered, nothing played; /music
833    // pick <n> plays one of them.
834    const asked = /^find\s+(.+)$/.exec(args)
835    if (asked !== null) {
836      if (isFinding) return { text: 'A search is already running.' }
837      const query = (asked[1] ?? '').trim()
838      const tracks = await find($, query)
839      if (tracks === undefined) return { text: `The search for "${query}" failed.` }
840      if (tracks.length === 0) return { text: `Nothing found on YouTube for "${query}".` }
841      return { text: `${listing(tracks)}\n\n/music pick <n> plays one.` }
842    }
843    const picked = /^pick\s+(\d+)$/.exec(args)
844    if (picked !== null) {
845      const f = await read($, found)
846      const n = Number(picked[1]) - 1
847      if (f.tracks.length === 0) return { text: 'Nothing found yet: /music find <search> first.' }
848      if (f.tracks[n] === undefined) return { text: `Pick 1 to ${f.tracks.length}.` }
849      const t = await once($, () => pick($, n))
850      return { text: t === undefined ? 'That one did not start.' : `Playing "${t.title}" (https://youtu.be/${t.id})` }
851    }
852
853    const track = await once($, () => play($, args, false))
854    if (track === undefined) return { text: `Nothing found on YouTube for "${args}".` }
855    return { text: `Playing "${track.title}" (https://youtu.be/${track.id})` }
856  })
857
858  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
859    const { Box, Text, Button, Input } = $.ui.resolve(e)
860    const p = await read($, player)
861    const level = await read($, volume)
862    const now = p.tracks[p.index]
863    const { value: avatar } = await $.state.get(onDuty)
864    const { value: onAir } = await $.state.get(onStation)
865    const station = onAir !== undefined && onAir.artists.length > 0 ? onAir : undefined
866    const isTerminal = e.surface === 'terminal'
867    // The border and the play glyph take three columns on the terminal.
868    const width = Math.max(10, (e.props.bodyColumns ?? 40) - (isTerminal ? 5 : 2))
869    const title = now === undefined ? 'Nothing plays.' : now.title.length > width ? `${now.title.slice(0, width - 1)}…` : now.title
870    const where = p.genre === null || p.genre === undefined ? `${p.index + 1}/${p.tracks.length}` : (GENRES.some(g => g.label === p.genre) ? p.genre : (onAir?.name || p.genre))
871
872    // A list, not a fragment: the terminal lays a fragment out as a column of
873    // its own, which stacked the controls one per row and cut off `u: vol+`.
874    const controls = [
875      <Button key="toggle" label={p.isPlaying ? 'pause' : 'play'} hotkey="p" plain onPress={() => void toggle($)} />,
876      <Button key="next" label="next" hotkey="n" plain onPress={() => void once($, () => skip($, 1))} />,
877      <Button key="similar" label="similar" hotkey="r" plain onPress={() => void once($, () => playSimilar($))} />,
878      <Button key="stop" label="stop" hotkey="s" plain dimColor onPress={() => void stop($)} />,
879      <Button key="quieter" label="vol−" hotkey="d" plain dimColor onPress={() => void louder($, -VOLUME_STEP)} />,
880      <Button key="louder" label="vol+" hotkey="u" plain dimColor onPress={() => void louder($, VOLUME_STEP)} />,
881    ]
882    const f = await read($, found)
883    const isSearching = await read($, searching)
884    const fit = (t: string, room: number): string => (t.length > room ? `${t.slice(0, room - 1)}…` : t)
885    const reopen = (open: boolean, results: number) => void $.ui.open({ id: PANE, title: 'jukebox7', rows: paneRows(open, results) })
886    const finder = (
887      <Button
888        key="find"
889        label={isSearching ? 'close search' : 'find'}
890        hotkey="f"
891        plain
892        dimColor={!isSearching}
893        onPress={() => {
894          void (async () => {
895            await update($, searching, () => !isSearching)
896            reopen(!isSearching, f.tracks.length)
897          })()
898        }}
899      />
900    )
901    const search = isSearching ? (
902      <Box flexDirection="column" backgroundColor={isTerminal ? BLACK : undefined}>
903        <Input
904          key="query"
905          label="find: "
906          placeholder={f.query === '' ? 'ctrl+x tab, a search, Enter' : f.query}
907          submitLabel="find"
908          autoFocus
909          onSubmit={value => {
910            const query = value.replace(/\s+/g, ' ').trim()
911            if (query === '' || isFinding) return
912            void (async () => {
913              const pending = find($, query)
914              $.ui.invalidate('ui.render')
915              const tracks = await pending
916              reopen(true, tracks?.length ?? 1)
917              $.ui.invalidate('ui.render')
918            })()
919          }}
920        />
921        {isFinding ? (
922          <Text dimColor>searching…</Text>
923        ) : f.query !== '' && f.tracks.length === 0 ? (
924          <Text dimColor>{`nothing found for "${f.query}"`}</Text>
925        ) : (
926          f.tracks.slice(0, PICK_KEYS.length).map((t, i) => (
927            <Button
928              key={`pick-${t.id}`}
929              label={fit(`${t.title}${t.seconds === null ? '' : ` (${clock(t.seconds)})`}`, width - 3)}
930              hotkey={PICK_KEYS[i]}
931              plain
932              dimColor={now?.id !== t.id}
933              onPress={() => void once($, () => pick($, i))}
934            />
935          ))
936        )}
937      </Box>
938    ) : null
939
940    const stations = (
941      <Box flexDirection="row" flexWrap="wrap" columnGap={2} backgroundColor={isTerminal ? BLACK : undefined}>
942        {GENRES.map(g => (
943          <Button key={g.label} label={g.label} hotkey={g.key} plain dimColor onPress={() => void once($, () => playGenre($, g.label))} />
944        ))}
945        {station !== undefined && avatar !== undefined && (
946          <Button key="avatar" label={`${station.name}'s pick`} hotkey="a" plain onPress={() => void once($, () => playGenre($, avatar))} />
947        )}
948        {isTerminal && finder}
949      </Box>
950    )
951
952    if (!isTerminal) {
953      return (
954        <Box flexDirection="column">
955          <Text dimColor={now === undefined || !p.isPlaying}>{title}</Text>
956          {now !== undefined && (
957            <Box flexDirection="row" gap={2}>
958              <Text dimColor>
959                {p.isPlaying ? 'playing' : 'paused'} {where} · vol {level}%
960              </Text>
961              {controls}
962            </Box>
963          )}
964          {stations}
965          {finder}
966          {search}
967        </Box>
968      )
969    }
970
971    // The terminal: black, framed and lit in the color of the avatar on duty.
972    const { value: color } = await $.state.get(tint)
973    const accent = typeof color === 'string' && color !== '' ? color : PHOSPHOR
974    const bar = progress(p, await $.clock.now(), now?.seconds ?? null)
975    const rows = e.props.scroll?.bodyRows ?? 0
976    // The frame, the title, the bar, the controls and the stations; the rain
977    // takes what is left of the pane's height.
978    // The controls and the stations wrap inside the frame (border and padding:
979    // four columns); each row they take comes out of the rain.
980    const inner = Math.max(10, (e.props.bodyColumns ?? 40) - 4)
981    const controlRows = now === undefined ? 0 : buttonRows([p.isPlaying ? 'pause' : 'play', 'next', 'similar', 'stop', 'vol−', 'vol+'], inner)
982    const stationRows = buttonRows(
983      [...GENRES.map(g => g.label), ...(station !== undefined ? [`${station.name}'s pick`] : []), isSearching ? 'close search' : 'find'],
984      inner,
985    )
986    // The field, then a result a row (or the one line saying why there are none).
987    const searchRows = isSearching ? 1 + (isFinding || f.tracks.length === 0 ? (f.query === '' && !isFinding ? 0 : 1) : Math.min(f.tracks.length, PICK_KEYS.length)) : 0
988    // The frame's two borders, the title, the bar when something plays.
989    const rainRows = Math.max(0, rows - 3 - (now === undefined ? 0 : 1) - controlRows - stationRows - searchRows)
990    const rainColumns = Math.max(0, (e.props.bodyColumns ?? 40) - 2)
991    return (
992      <Box flexDirection="column" width="100%" height={rows > 0 ? rows : undefined} backgroundColor={BLACK}>
993        <Box flexDirection="column" borderStyle="round" borderColor={accent} backgroundColor={BLACK} paddingX={1}>
994          <Text color={accent} backgroundColor={BLACK} dimColor={now === undefined || !p.isPlaying}>
995            {now === undefined ? '■ ' : p.isPlaying ? '▶ ' : '❚❚ '}
996            {title}
997          </Text>
998          {now !== undefined && (
999            <Text backgroundColor={BLACK}>
1000              <Text color={accent}>{bar.done}</Text>
1001              <Text dimColor>{bar.left}</Text>
1002              <Text dimColor>{` ${bar.time}  ${where}  vol ${level}%`}</Text>
1003            </Text>
1004          )}
1005          {now !== undefined && (
1006            <Box flexDirection="row" flexWrap="wrap" columnGap={2} backgroundColor={BLACK}>
1007              {controls}
1008            </Box>
1009          )}
1010          {stations}
1011          {search}
1012        </Box>
1013        <Box flexDirection="column" flexGrow={1} paddingX={1} backgroundColor={BLACK}>
1014          {rain(rainColumns, rainRows, frame).map((runs, r) => (
1015            <Text key={String(r)} backgroundColor={BLACK}>
1016              {runs.map((run, i) => (
1017                <Text key={String(i)} color={run.level === 2 ? '#e8fff4' : accent} dimColor={run.level === 1}>
1018                  {run.text}
1019                </Text>
1020              ))}
1021            </Text>
1022          ))}
1023        </Box>
1024      </Box>
1025    )
1026  })
1027}
1028
hooks/remote.ts 71 lines
1// jukebox7's remote branch: pure strings, run for real by
2// tools/test_remote_shell.ts (the plugin tests mock every process).
3//
4// While avatar7's relay is held (the voice is on the phone), the music goes
5// there too: the song is written for the relay (music.json), the phone streams
6// it through the relay, and this process waits for the phone to say it ended
7// (or skipped it): then it ends, and the poll moves on as when VLC ends a song.
8// Given back meanwhile, it ends too, and the next song plays here. Its exit
9// clears music.json unless a newer song took it already. No single quote: it
10// runs inside one.
11export const RELAY = '"\${XDG_CACHE_HOME:-$HOME/.cache}/avatar7/relay"'
12export const REMOTE =
13  `R=${RELAY}; if [ -f "$R/owner" ]; then rm -f "$R/ended-$1"; ` +
14  `printf "{\\"id\\": \\"%s\\", \\"paused\\": false}" "$1" > "$R/music.part" && mv "$R/music.part" "$R/music.json"; ` +
15  `trap "grep -q $1 $R/music.json 2>/dev/null && printf {} > $R/music.part && mv $R/music.part $R/music.json" EXIT; trap exit TERM; ` +
16  `while [ ! -f "$R/ended-$1" ] && [ -f "$R/owner" ]; do sleep 1; done; rm -f "$R/ended-$1"; exit; fi; `
17
18// Prints `up` while a session holds the relay: the poll watches it change,
19// so a song already playing here moves to the phone when the voice does.
20export const heldArgv = (): string[] => ['sh', '-c', `R=${RELAY}; [ -f "$R/owner" ] && echo up; true`, 'jukebox7-held']
21
22// Pause on the phone flips the flag of the song music.json holds; otherwise
23// the arguments after the script (VLC's curl) run as they are.
24export const pauseScript = (isPaused: boolean): string =>
25  `R=${RELAY}; if [ -f "$R/owner" ] && grep -q '"id"' "$R/music.json" 2>/dev/null; then ` +
26  `sed 's/"paused": [a-z]*/"paused": ${isPaused}/' "$R/music.json" > "$R/music.part" && mv "$R/music.part" "$R/music.json"; else exec "$@"; fi`
27
28// The phone's presses for the jukebox, queued by the relay one JSON file each
29// under jukebox/: read and removed in one go, a line each.
30export const drainArgv = (): string[] => [
31  'sh',
32  '-c',
33  `R=${RELAY}; for f in "$R"/jukebox/*.json; do [ -f "$f" ] && cat "$f" && echo && rm -f "$f"; done; true`,
34]
35
36// What the jukebox shows the phone (title, playing, genre, the genres), from
37// stdin, while a session holds the relay.
38export const statusArgv = (): string[] => [
39  'sh',
40  '-c',
41  `R=${RELAY}; [ -f "$R/owner" ] && cat > "$R/jukebox.part" && mv "$R/jukebox.part" "$R/jukebox.json"; true`,
42]
43
44export type JukeboxPress =
45  | { do: 'pause' | 'next' | 'similar' | 'stop' }
46  | { do: 'genre'; genre: string }
47  | { do: 'find'; query: string }
48  | { do: 'pick'; index: number }
49
50// A search asked from the phone, cut as the relay cuts it.
51export const QUERY_MAX = 120
52
53// A press as the relay queued it, or undefined for anything else: a genre
54// must be one of the given labels.
55export const parseJukebox = (line: string, genres: string[]): JukeboxPress | undefined => {
56  let r: unknown
57  try {
58    r = JSON.parse(line)
59  } catch {
60    return undefined
61  }
62  if (typeof r !== 'object' || r === null) return undefined
63  const o = r as Record<string, unknown>
64  if (o.do === 'pause' || o.do === 'next' || o.do === 'similar' || o.do === 'stop') return { do: o.do }
65  if (o.do === 'genre' && typeof o.genre === 'string' && genres.includes(o.genre)) return { do: 'genre', genre: o.genre }
66  if (o.do === 'find' && typeof o.query === 'string' && o.query.trim() !== '') return { do: 'find', query: o.query.trim().slice(0, QUERY_MAX) }
67  // Which result of the last search, from 0; checked against the list when it plays.
68  if (o.do === 'pick' && typeof o.index === 'number' && Number.isInteger(o.index) && o.index >= 0) return { do: 'pick', index: o.index }
69  return undefined
70}
71
types/index.d.ts 37 lines
1export type Track = { id: string; title: string; seconds: number | null }
2
3// What plays: the search results, the one playing, and the process group of
4// its yt-dlp | VLC pipeline. Kept in $.state so a reload still owns it.
5export type Player = {
6  tracks: Track[]
7  index: number
8  pgid: number | null
9  isPlaying: boolean
10  // The genre button behind it: next and the end of a song roll again there.
11  genre: string | null
12  // When the track started, pushed forward by each pause, and when the
13  // current pause began (ms, $.clock.now): what the progress bar reads.
14  startedAt: number | null
15  pausedAt: number | null
16}
17
18// What this mod asks avatar7 to say, if avatar7 is loaded: each song as it
19// starts, for the persona to introduce like a radio host; a search that found
20// nothing. avatar7 hears the write; nothing here depends on it.
21export type Say = { mood: 'watch' | 'error'; event: string; at: number }
22
23// The last search asked for a pick (/music find, the pane's f, the phone):
24// what was asked and the results, in YouTube's order.
25export type Found = { query: string; tracks: Track[] }
26
27// One model call of this mod (the intent of a prompt), for usage-bell's tally.
28export type ModelUse = { model: string; input: number; output: number; cacheRead: number; at: number }
29
30declare module 'claude-code' {
31  interface PluginState {
32    jukebox7: { player: Player; volume: number; say: Say; found: Found; searching: boolean; modelUse: ModelUse }
33    // Read only: avatar7 owns it and says which face is on duty.
34    avatar7: { avatar: string; color: string; isVoicing: boolean; station: { name: string; artists: string[] } }
35  }
36}
37