SLOPSHOPPER

now-playing

A live now-playing line above the prompt: artist, title and a moving progress bar. On Windows from the media session (YouTube Music in Chrome included); on a…

bandcommandmodelprocessnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · now-playing
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 › /np ⎿ now-playing: Player controls need Windows or a Mac. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

sanduhr-mods

Claude Code plugins, published from Sanduhr.

Add the marketplace once, then install any plugin in it by name:

claude plugin marketplace add estevanhernandez-stack-ed/sanduhr-mods
claude plugin install <plugin>@sanduhr-mods

Plugins

<!-- sanduhr:plugins -->

  • now-playing 0.3.1: A live now-playing line above the prompt: artist, title and a moving progress bar. On Windows from the media session (YouTube Music in Chrome included); on a Mac from every player through Sanduhr's adapter, or Music and Spotify without it.
  claude plugin install now-playing --marketplace estevanhernandez-stack-ed/sanduhr-mods
  • finish-line 0.1.1: When a turn creates a tag (git tag, gh release create), a Claude sprite runs across the band above the prompt and crosses a checkered finish line, and the tag's name lands beside it. /finish-line [tag] replays it. Reads nothing, writes nothing, no network.
  claude plugin install finish-line --marketplace estevanhernandez-stack-ed/sanduhr-mods
  • agent-comms 0.2.1: A band above the prompt showing every message to another agent that is still waiting on a reply.
  claude plugin install agent-comms --marketplace estevanhernandez-stack-ed/sanduhr-mods
  • decision-logger 0.1.1: Pushes the dashboard decision log hard: a standing instruction, a nudge after commits and edit-heavy turns, and a running count.
  claude plugin install decision-logger --marketplace estevanhernandez-stack-ed/sanduhr-mods
  • page-pane 0.1.1: /page <project> opens a side pane with that project's 626labs.dev portfolio card: summary, banner and link.
  claude plugin install page-pane --marketplace estevanhernandez-stack-ed/sanduhr-mods
  • desk-setup 0.1.0: A Claude Code skill that sets up your Sanduhr Desk as a conversation: it reads the screen and wallpaper, asks a few short questions, proposes a small look you Apply on the live preview, then offers a finishing widget such as a pixel companion or song looks. Windows, Sanduhr 3.7 or later; widgets need 3.8.
  claude plugin install desk-setup --marketplace estevanhernandez-stack-ed/sanduhr-mods

Then ask Claude Code to set up your Desk ("set up my Desk on the second screen"). Every change previews on the desktop and waits for your Apply.

<!-- /sanduhr:plugins -->

Mods to build for your own setup

Some mods are tied to one person's machines: a home server, a media box, a dashboard. Those aren't published here as code, because the code would carry someone else's hosts. What travels instead is the idea and a prompt. Paste a prompt into Claude Code (the plugin-authoring skill ships with it), fill in the parts in angle brackets, and Claude builds the mod for your setup; Sanduhr's New Mod page then keeps it, checks it and turns it on per folder.

Home lab pulse

A status-line glyph and a toast when something at home goes down or comes back, read from whatever already knows (an Uptime Kuma instance, a Tailscale tailnet, a Home Assistant), polled slowly over SSH or HTTP. /lab lists every monitor and peer on demand.

Build me a Claude Code mod called homelab-pulse with the plugin-authoring skill.
It polls my home lab every 60 seconds and shows a short status-line entry: a
check mark when everything is up, the count of what is down otherwise, and
"lab ?" when the source can't be reached. It toasts once when a monitor drops
and once when it recovers, never on every poll. A /lab command prints every
monitor and peer with its state.
Source of truth: <how to read your monitors, e.g. `ssh <host> …` printing JSON,
an HTTP endpoint, or `tailscale status --json`>. Run it with $.process.run with
a 20 s timeout, or $.http.fetch; parse only the fields you show.
Keep all state in an atom; no files, no network beyond that one source; no
credentials in the mod (use an SSH host alias or a local-network URL).
Add register.test.ts covering: all up, one down, source unreachable, recovery.

A quiet pip for a media server

Two characters in the status line while a media server answers, nothing otherwise. No alerts, slow polling, no token.

Build me a Claude Code mod called plex-pip with the plugin-authoring skill.
Every <5> minutes it fetches <your server's unauthenticated health URL, e.g.
http://<host>:32400/identity for Plex, or /System/Ping for Jellyfin> with
$.http.fetch. While the response is ok it shows "🎬🎵" in the status line via
$.ui.status; on any error or non-2xx it shows nothing. Read the URL and the
interval from plugin options (plexUrl, pollMinutes) so the mod ships without my
address in it; do nothing when the URL option is empty. No toasts, no commands,
no state beyond the last result. Add register.test.ts for ok, non-2xx, a thrown
fetch and an empty option.

If you build one, publish it to your own marketplace from Sanduhr (New Mod, a kept mod's menu, Publish to Marketplace…).

Source 7 files
hooks/register.tsx 487 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { NowLyrics, NowTheme, NowTrack } from '../types'
5import { GLOW_FRAME_MS, LYRIC_SHINE_MS, TITLE_SHINE_MS, applyGlow, glowAt, shouldRepaint, sweepAt } from './glow'
6import { currentLine, lyricsUrl, parseLrc, trackKey } from './lyrics'
7import { STYLE_SYSTEM, defaultTheme, gradient, hash, offerJson, parseSanduhrLook, parseTheme, sanduhrFiles, songKey, stylePrompt, styleText } from './theme'
8import type { FontName, Theme } from './theme'
9import { cleanTitle, clock, livePosition, parseLine } from './track'
10import { WATCHER_ARGV, adapterIn, controlArgv, macControlArgv, macWatcherArgv, parseControl, sanduhrContents } from './watcher'
11import type { Adapter, Control } from './watcher'
12
13const current = atom({ plugin: 'now-playing', key: 'track' } as const, null as NowTrack | null)
14const lyrics = atom({ plugin: 'now-playing', key: 'lyrics' } as const, null as NowLyrics | null)
15const theme = atom({ plugin: 'now-playing', key: 'theme' } as const, null as NowTheme | null)
16const controlsUntil = atom({ plugin: 'now-playing', key: 'controlsUntil' } as const, 0)
17// When the title last started a shine: a new song, or the agent's look landing.
18const shineAt = atom({ plugin: 'now-playing', key: 'shineAt' } as const, 0)
19
20const SAID: Record<Control, string> = { toggle: 'Play/pause sent.', next: 'Skipped.', prev: 'Back one.' }
21const CONTROLS_MS = 8000
22const STYLE_MODEL = 'haiku'
23const TITLE_MAX = 40
24const BAR_WIDTH = 18
25// Sanduhr writes its look for a song a moment after the track changes; give it this long before asking the model.
26const SANDUHR_WAIT_MS = 1500
27// The hover group: pointing anywhere on the now-playing line lights it and reveals the controls.
28const HOVER = 'np-band'
29
30// Where the track comes from, decided at session start: Windows' media session, a Mac (with
31// Sanduhr's adapter when installed), or nothing.
32type Player = { watch: readonly string[]; control: (action: Control) => string[] }
33let player: Player | null = null
34// Sanduhr's files for the mod (Windows only; the Mac app does not write them yet). Null elsewhere.
35let sanduhr: ReturnType<typeof sanduhrFiles> = null
36// The song key last offered to Sanduhr, so one song is offered once however many readings follow.
37let offered: string | null = null
38
39let ticker: { cancel: () => void } | null = null
40let lastPaint = 0
41// A drawing that starts a shine asks for fast frames until it ends.
42let fastUntil = 0
43
44// While playing: fast frames only while light moves (the bar's pulse, a title or lyric shine),
45// else once a second for the clock and lyric. Nothing redraws while paused or silent.
46function tick($: EngineInterface, track: NowTrack | null) {
47  const isPlaying = track !== null && track.status === 'Playing'
48
49  if (isPlaying && ticker === null) {
50    ticker = $.clock.every(GLOW_FRAME_MS, () => {
51      void $.clock.now().then(now => {
52        if (shouldRepaint(now, lastPaint, fastUntil)) {
53          lastPaint = now
54          $.ui.invalidate('ui.render')
55        }
56      })
57    })
58  } else if (!isPlaying && ticker !== null) {
59    ticker.cancel()
60    ticker = null
61  }
62}
63
64async function showControls($: EngineInterface) {
65  const now = await $.clock.now()
66  await update($, controlsUntil, () => now + CONTROLS_MS)
67}
68
69async function shine($: EngineInterface) {
70  const now = await $.clock.now()
71  await update($, shineAt, () => now)
72}
73
74// One lookup per song. Synced lyrics only: an unsynced line shown at the wrong time is worse than none.
75async function fetchLyrics($: EngineInterface, track: NowTrack) {
76  const key = trackKey(track.artist, track.title)
77  await update($, lyrics, () => null)
78
79  const res = await $.http.fetch(lyricsUrl(track.artist, cleanTitle(track.title), track.endMs), {
80    headers: { 'User-Agent': 'now-playing (Claude Code mod)' },
81  })
82
83  if (!res.ok) {
84    return
85  }
86
87  const body = JSON.parse(res.text) as { syncedLyrics?: string | null }
88
89  if (!body.syncedLyrics) {
90    return
91  }
92
93  // The song may have changed while the lookup was out.
94  const playing = await read($, current)
95
96  if (playing === null || trackKey(playing.artist, playing.title) !== key) {
97    return
98  }
99
100  await update($, lyrics, () => ({ key, lines: parseLrc(body.syncedLyrics as string) }))
101}
102
103// Sanduhr's look for the song, when its file is there and names this song: the one it chose or seeded.
104async function sanduhrLook($: EngineInterface, id: string | null) {
105  if (sanduhr === null || id === null) {
106    return null
107  }
108
109  try {
110    const look = parseSanduhrLook(await $.fs.read(sanduhr.look))
111
112    return look !== null && look.key === id ? look : null
113  } catch {
114    return null
115  }
116}
117
118// Puts Sanduhr's look on the song if it is not there already; the title shines when it changes the look.
119async function applySanduhr($: EngineInterface, key: string, id: string | null): Promise<boolean> {
120  const look = await sanduhrLook($, id)
121  const now = await read($, theme)
122
123  if (look === null || now === null || now.key !== key) {
124    return false
125  }
126
127  const same = now.source === 'sanduhr' && now.font === look.theme.font && now.colors.join() === look.theme.colors.join()
128
129  if (!same) {
130    await update($, theme, t => (t !== null && t.key === key ? { key, ...look.theme, source: 'sanduhr' } : t))
131    await shine($)
132  }
133
134  return true
135}
136
137// The mod's own remembered pick: the song's key first, else the 32-bit entry older versions wrote (moved over once).
138async function remembered($: EngineInterface, key: string, id: string | null) {
139  const legacyKey = `theme:${hash(key)}`
140  const storeKey = id === null ? legacyKey : `theme:${id}`
141  const picked = parseTheme(JSON.stringify((await $.store.get(storeKey)) ?? ''))
142
143  if (picked !== null || storeKey === legacyKey) {
144    return picked
145  }
146
147  const old = parseTheme(JSON.stringify((await $.store.get(legacyKey)) ?? ''))
148
149  if (old !== null) {
150    await $.store.set(storeKey, old)
151  }
152
153  return old
154}
155
156// Hands the model's pick to Sanduhr as a proposal: only on Windows with Sanduhr's folder there, only when its current
157// look for this song is not one it saved, and once per song. One write of the whole JSON; Sanduhr tolerates a partial file.
158async function offerToSanduhr($: EngineInterface, track: NowTrack, id: string | null, picked: Theme) {
159  if (sanduhr === null || id === null || offered === id || !(await $.fs.exists(sanduhr.dir).catch(() => false))) {
160    return
161  }
162
163  if ((await sanduhrLook($, id))?.source === 'saved') {
164    return
165  }
166
167  offered = id
168  await $.fs.write(sanduhr.offer, offerJson(id, track.title, track.artist, picked))
169}
170
171// The look for a song: the default draws at once; Sanduhr's look, if it has one, replaces it with no model call;
172// else a remembered pick; else the model's.
173async function styleSong($: EngineInterface, track: NowTrack) {
174  const key = trackKey(track.artist, track.title)
175  const id = songKey(track.title, track.artist)
176  await update($, theme, () => ({ key, ...defaultTheme(key), source: 'default' }))
177
178  if (await applySanduhr($, key, id)) {
179    return
180  }
181
182  // Sanduhr installed: its file may lag the track change by a moment, so wait before spending a model call.
183  if (sanduhr !== null && id !== null && (await $.fs.exists(sanduhr.dir).catch(() => false))) {
184    await $.clock.sleep(SANDUHR_WAIT_MS)
185
186    const playing = await read($, current)
187
188    if (playing === null || trackKey(playing.artist, playing.title) !== key || (await applySanduhr($, key, id))) {
189      return
190    }
191  }
192
193  const storeKey = id === null ? `theme:${hash(key)}` : `theme:${id}`
194  const kept = await remembered($, key, id)
195
196  if (kept !== null) {
197    await update($, theme, t => (t !== null && t.key === key && t.source !== 'sanduhr' ? { key, ...kept, source: 'remembered' } : t))
198
199    return
200  }
201
202  const answer = await $.model.complete({
203    model: STYLE_MODEL,
204    system: STYLE_SYSTEM,
205    prompt: stylePrompt(track.artist, cleanTitle(track.title)),
206    maxTokens: 200,
207    effort: 'low',
208    // Only bounds a hung call: the default is already on screen, and a late pick still lands mid-song.
209    timeoutMs: 20000,
210  })
211  const picked = answer.isAnswered ? parseTheme(answer.text) : null
212
213  if (picked === null) {
214    return
215  }
216
217  await $.store.set(storeKey, picked)
218  await offerToSanduhr($, track, id, picked).catch(() => undefined)
219
220  // Only swap if the same song is still on (and Sanduhr has not set its own look), and shine the title so the change reads as a moment.
221  const swapped = await update($, theme, t => (t !== null && t.key === key && t.source !== 'sanduhr' ? { key, ...picked, source: 'model' } : t))
222
223  if (swapped?.source === 'model') {
224    await shine($)
225  }
226}
227
228// A file the module can read, or not. Reading a small known file is the engine's only way to look.
229async function exists($: EngineInterface, path: string): Promise<boolean> {
230  try {
231    await $.fs.read(path)
232    return true
233  } catch {
234    return false
235  }
236}
237
238async function findPlayer($: EngineInterface): Promise<Player | null> {
239  if ((await $.env.get('OS')) === 'Windows_NT') {
240    return { watch: WATCHER_ARGV, control: controlArgv }
241  }
242
243  if (!(await exists($, '/System/Library/CoreServices/SystemVersion.plist'))) {
244    return null
245  }
246
247  let adapter: Adapter | null = null
248
249  for (const contents of sanduhrContents(await $.env.get('HOME'))) {
250    if (await exists($, adapterIn(contents).script)) {
251      adapter = adapterIn(contents)
252      break
253    }
254  }
255
256  return { watch: macWatcherArgv(adapter), control: action => macControlArgv(action, adapter) }
257}
258
259// One watcher process for the session's life. Leaving the loop (or unloading the module) kills it.
260async function watch($: EngineInterface, argv: readonly string[]) {
261  let buffer = ''
262  let last = ''
263  let song = ''
264
265  for await (const chunk of $.process.spawn({ argv })) {
266    if (chunk.stream !== 'stdout') {
267      continue
268    }
269
270    buffer += chunk.text
271    const lines = buffer.split(/\r?\n/)
272    buffer = lines.pop() ?? ''
273
274    for (const line of lines) {
275      const track = parseLine(line.trim())
276      const key = JSON.stringify(track)
277
278      // Sanduhr may write or change its look at any moment of a song: look again on every reading.
279      if (track !== null) {
280        void applySanduhr($, trackKey(track.artist, track.title), songKey(track.title, track.artist)).catch(() => undefined)
281      }
282
283      if (key === last) {
284        continue
285      }
286
287      last = key
288      await update($, current, () => track)
289      tick($, track)
290
291      const nextSong = track === null ? '' : trackKey(track.artist, track.title)
292
293      if (nextSong !== song) {
294        song = nextSong
295
296        if (track !== null) {
297          await showControls($)
298          await shine($)
299          void fetchLyrics($, track).catch(() => undefined)
300          void styleSong($, track).catch(() => undefined)
301        }
302      }
303    }
304  }
305}
306
307// Sends one command to the player. A toggle flips the band at once instead of waiting for the next read.
308async function control($: EngineInterface, action: Control): Promise<string> {
309  let said = ''
310  await showControls($)
311
312  if (player === null) {
313    return 'Player controls need Windows or a Mac.'
314  }
315
316  try {
317    said = (await $.process.run(player.control(action), { timeoutMs: 15000 })).stdout.trim()
318  } catch {
319    return 'The player controls could not start.'
320  }
321
322  if (said === 'none') {
323    return 'Nothing is playing.'
324  }
325
326  if (said !== 'ok') {
327    return 'The player refused that.'
328  }
329
330  if (action === 'toggle') {
331    const now = await $.clock.now()
332    const flipped = await update($, current, t =>
333      t === null
334        ? t
335        : { ...t, posMs: livePosition(t, now), stampMs: now, status: t.status === 'Playing' ? 'Paused' : 'Playing' },
336    )
337    tick($, flipped)
338  }
339
340  return SAID[action]
341}
342
343export const register: Register = on => {
344  on('session.start', async ($, e, next) => {
345    const started = await next(e)
346
347    await $.command.register({
348      name: 'np',
349      description: 'Control what is playing: /np (play/pause), /np next, /np prev',
350      argumentHint: '[pause|next|prev]',
351    })
352
353    player = await findPlayer($)
354    sanduhr = (await $.env.get('OS')) === 'Windows_NT' ? sanduhrFiles(await $.env.get('USERPROFILE')) : null
355
356    if (player !== null) {
357      // A reload or session end tears the loop down mid-read; that is the normal way it stops.
358      void watch($, player.watch).catch(() => undefined)
359    }
360
361    return started
362  })
363
364  on('command.run', { command: 'np' }, async ($, e) => {
365    const action = parseControl(e.args)
366
367    if (action === undefined) {
368      return { text: 'Use /np, /np next or /np prev.' }
369    }
370
371    return { text: await control($, action) }
372  })
373
374  // Shares the band: draws its own lines above whatever the plugins beneath draw.
375  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
376    const below = await next(e)
377    const track = await read($, current)
378
379    if (e.props.hasSurvey || track === null) {
380      return below
381    }
382
383    const { Box, Button, Text } = $.ui.resolve(e)
384    const now = await $.clock.now()
385    const key = trackKey(track.artist, track.title)
386    const look = await read($, theme)
387    const style = look !== null && look.key === key ? look : { ...defaultTheme(key), source: 'default' }
388    const isPlaying = track.status === 'Playing'
389    const pos = livePosition(track, now)
390
391    const accent = style.colors[Math.floor(style.colors.length / 2)]
392    // Asks the ticker for fast frames until a shine drawn here has finished.
393    const keepFast = (ms: number) => {
394      fastUntil = Math.max(fastUntil, now + ms)
395    }
396
397    // Title: the song's gradient in its letter style, and a sweep of light when the song or its look lands.
398    const rawTitle = cleanTitle(track.title)
399    const title = rawTitle.length > TITLE_MAX ? `${rawTitle.slice(0, TITLE_MAX - 1)}…` : rawTitle
400    const titleCells = gradient(styleText(title, style.font as FontName), style.colors)
401    const titleShine = now - (await read($, shineAt))
402    const titleLit = titleShine < TITLE_SHINE_MS ? applyGlow(titleCells, sweepAt(titleShine / TITLE_SHINE_MS, titleCells.length), 0.85) : titleCells
403
404    if (titleShine < TITLE_SHINE_MS) {
405      keepFast(TITLE_SHINE_MS - titleShine)
406    }
407
408    // Bar: the filled run in the song's gradient, with a pulse of light flowing into the playhead.
409    const filled = track.endMs > 0 ? Math.min(BAR_WIDTH - 1, Math.floor((pos / track.endMs) * BAR_WIDTH)) : -1
410    const barCells = gradient('━'.repeat(Math.max(filled, 0)) + '●', style.colors)
411    const barLit = isPlaying ? applyGlow(barCells, glowAt(now, barCells.length)) : barCells
412
413    // Lyric: the line being sung, in the accent color, caught by a quick sweep as it starts.
414    const words = await read($, lyrics)
415    const line = words !== null && words.key === key ? currentLine(words.lines, pos) : null
416    const lineAge = line === null ? Infinity : pos - line.ms
417    const lyricCells = line === null ? [] : [...line.text].map(ch => ({ ch, color: accent }))
418    const lyricLit = isPlaying && lineAge < LYRIC_SHINE_MS ? applyGlow(lyricCells, sweepAt(lineAge / LYRIC_SHINE_MS, lyricCells.length), 0.6) : lyricCells
419
420    if (isPlaying && lineAge < LYRIC_SHINE_MS) {
421      keepFast(LYRIC_SHINE_MS - lineAge)
422    }
423
424    // Controls show after a song change or a press, stay while paused (that is when you reach for
425    // them), and come back whenever the pointer is anywhere on the now-playing line.
426    const isShowingControls = !isPlaying || now < (await read($, controlsUntil))
427    const glyphHover = { scope: HOVER, color: accent, bold: true } as const
428
429    return (
430      <Box flexDirection="column">
431        <Box key="np-row" gap={1} hover={{ scope: HOVER }}>
432          <Text color={accent}>{isPlaying ? '♪' : '❚❚'}</Text>
433          {/* Paused, the line steps back to just the mark, bar, time and controls. */}
434          {isPlaying && (
435          <Box>
436            {titleLit.map((c, i) => (
437              <Text key={`t${i}`} color={c.color} bold>
438                {c.ch}
439              </Text>
440            ))}
441          </Box>
442          )}
443          {isPlaying && track.artist !== '' && <Text dimColor>· {track.artist}</Text>}
444          {filled >= 0 && (
445            <Box>
446              {barLit.map((c, i) => (
447                <Text key={`b${i}`} color={c.color}>
448                  {c.ch}
449                </Text>
450              ))}
451              <Text dimColor>{'─'.repeat(BAR_WIDTH - 1 - filled)}</Text>
452            </Box>
453          )}
454          {track.endMs > 0 && (
455            <Text dimColor>
456              {clock(pos)} / {clock(track.endMs)}
457            </Text>
458          )}
459          <Box
460            key="np-controls"
461            display={isShowingControls ? 'flex' : 'none'}
462            hover={isShowingControls ? undefined : { scope: HOVER, display: 'flex' }}
463            gap={1}
464          >
465            {/* Plain text words, no fill: the terminal draws the media icons as emoji tiles. */}
466            <Button key="np-prev" label="prev" plain dimColor hover={glyphHover} onPress={() => control($, 'prev')} />
467            <Text dimColor>·</Text>
468            <Button key="np-toggle" label={isPlaying ? 'pause' : 'play'} plain hover={glyphHover} onPress={() => control($, 'toggle')} />
469            <Text dimColor>·</Text>
470            <Button key="np-next" label="next" plain dimColor hover={glyphHover} onPress={() => control($, 'next')} />
471          </Box>
472        </Box>
473        {isPlaying && lyricLit.length > 0 && (
474          <Box key="np-lyric" paddingLeft={2}>
475            {lyricLit.map((c, i) => (
476              <Text key={`l${i}`} color={c.color} italic>
477                {c.ch}
478              </Text>
479            ))}
480          </Box>
481        )}
482        {below}
483      </Box>
484    )
485  })
486}
487
hooks/glow.ts 69 lines
1// Light that sweeps across a run of colored characters: the bar's pulse, the title's
2// shine when a song or its look lands, the lyric's shine when a new line starts.
3// Pure helpers, no `$`.
4
5export const GLOW_PERIOD_MS = 4000 // the bar pulses every 4s
6export const GLOW_RUN_MS = 1100 // each bar pulse crosses in about a second
7export const GLOW_WIDTH = 3 // how many characters the light spans
8export const GLOW_FRAME_MS = 80 // repaint rate while any light is moving
9export const TITLE_SHINE_MS = 1400 // one sweep across the title
10export const LYRIC_SHINE_MS = 900 // one sweep across a new lyric line
11
12// Where a sweep `fraction` (0 to 1) of the way through sits on `length` characters. It starts
13// just before the first and ends just past the last, so the light enters and leaves softly.
14export function sweepAt(fraction: number, length: number): number | null {
15  if (fraction < 0 || fraction >= 1 || length <= 0) {
16    return null
17  }
18
19  return fraction * (length + GLOW_WIDTH * 2) - GLOW_WIDTH
20}
21
22// The bar's pulse, or null between pulses.
23export function glowAt(nowMs: number, length: number): number | null {
24  return sweepAt((nowMs % GLOW_PERIOD_MS) / GLOW_RUN_MS, length)
25}
26
27export function isGlowing(nowMs: number): boolean {
28  return nowMs % GLOW_PERIOD_MS < GLOW_RUN_MS
29}
30
31// Fast frames while any light moves (the bar's pulse, or a shine running until `fastUntil`),
32// else only when the second changes, for the clock and the bar's length.
33export function shouldRepaint(nowMs: number, lastMs: number, fastUntil = 0): boolean {
34  return (
35    isGlowing(nowMs) ||
36    isGlowing(lastMs) ||
37    nowMs < fastUntil ||
38    lastMs < fastUntil ||
39    Math.floor(nowMs / 1000) !== Math.floor(lastMs / 1000)
40  )
41}
42
43const rgb = (h: string): number[] => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
44const toHex = (c: number[]): string => `#${c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
45const clamp = (k: number): number => Math.min(1, Math.max(0, k))
46
47// Blend toward white by k (0 to 1).
48export function brighten(hex: string, k: number): string {
49  return toHex(rgb(hex).map(v => v + (255 - v) * clamp(k)))
50}
51
52// Blend toward black by k (0 to 1): a deep tint of a color, for a background.
53export function shade(hex: string, k: number): string {
54  return toHex(rgb(hex).map(v => v * (1 - clamp(k))))
55}
56
57// Light the characters near `at`, brightest at its center.
58export function applyGlow<T extends { color: string }>(cells: readonly T[], at: number | null, strength = 0.75): T[] {
59  if (at === null) {
60    return [...cells]
61  }
62
63  return cells.map((cell, i) => {
64    const k = 1 - Math.abs(i - at) / GLOW_WIDTH
65
66    return k > 0 ? { ...cell, color: brighten(cell.color, k * strength) } : cell
67  })
68}
69
hooks/lyrics.ts 56 lines
1// Synced lyrics from LRCLIB (lrclib.net): an open database, no key. Pure helpers, no `$`.
2
3export type LyricLine = { ms: number; text: string }
4
5const STAMP = /\[(\d+):(\d+(?:\.\d+)?)\]/g
6
7// LRC: `[mm:ss.xx] text`. One line may carry several stamps (a repeated chorus).
8export function parseLrc(lrc: string): LyricLine[] {
9  const out: LyricLine[] = []
10
11  for (const raw of lrc.split(/\r?\n/)) {
12    const stamps = [...raw.matchAll(STAMP)]
13    const text = raw.replace(STAMP, '').trim()
14
15    for (const m of stamps) {
16      out.push({ ms: Math.round((Number(m[1]) * 60 + Number(m[2])) * 1000), text })
17    }
18  }
19
20  return out.sort((a, b) => a.ms - b.ms)
21}
22
23// The line being sung at `posMs` with the time it started: the last one whose stamp has passed.
24// Blank lines are pauses.
25export function currentLine(lines: readonly LyricLine[], posMs: number): LyricLine | null {
26  let hit: LyricLine | null = null
27
28  for (const line of lines) {
29    if (line.ms > posMs) {
30      break
31    }
32
33    hit = line
34  }
35
36  return hit && hit.text !== '' ? hit : null
37}
38
39export function lineAt(lines: readonly LyricLine[], posMs: number): string | null {
40  return currentLine(lines, posMs)?.text ?? null
41}
42
43export function lyricsUrl(artist: string, title: string, durationMs: number): string {
44  const q = [
45    `artist_name=${encodeURIComponent(artist)}`,
46    `track_name=${encodeURIComponent(title)}`,
47    durationMs > 0 ? `duration=${Math.round(durationMs / 1000)}` : '',
48  ].filter(Boolean)
49
50  return `https://lrclib.net/api/get?${q.join('&')}`
51}
52
53export function trackKey(artist: string, title: string): string {
54  return `${artist}\u0000${title}`
55}
56
hooks/theme.ts 283 lines
1// Per-song look for the band: a Unicode letter style ("font") and a color gradient.
2// Pure helpers, no `$`. The model picks; these defaults cover the gap until it answers.
3
4export type FontName = 'plain' | 'bold' | 'italic' | 'bolditalic' | 'sans' | 'mono' | 'double' | 'script' | 'fraktur' | 'smallcaps'
5
6export type Theme = { font: FontName; colors: string[]; mood: string }
7
8export const FONTS: readonly FontName[] = ['plain', 'bold', 'italic', 'bolditalic', 'sans', 'mono', 'double', 'script', 'fraktur', 'smallcaps']
9
10// Mathematical alphanumeric blocks: [upper A, lower a, digit 0 or 0 when the block has none].
11const BLOCKS: Partial<Record<FontName, [number, number, number]>> = {
12  bold: [0x1d400, 0x1d41a, 0x1d7ce],
13  italic: [0x1d434, 0x1d44e, 0],
14  bolditalic: [0x1d468, 0x1d482, 0],
15  sans: [0x1d5d4, 0x1d5ee, 0x1d7ec],
16  mono: [0x1d670, 0x1d68a, 0x1d7f6],
17  double: [0x1d538, 0x1d552, 0x1d7d8],
18  script: [0x1d49c, 0x1d4b6, 0],
19  fraktur: [0x1d504, 0x1d51e, 0],
20}
21
22// Letters Unicode placed in the Letterlike block instead (the gaps in the ranges above).
23const HOLES: Partial<Record<FontName, Record<string, string>>> = {
24  italic: { h: 'ℎ' },
25  double: { C: 'ℂ', H: 'ℍ', N: 'ℕ', P: 'ℙ', Q: 'ℚ', R: 'ℝ', Z: 'ℤ' },
26  script: { B: 'ℬ', E: 'ℰ', F: 'ℱ', H: 'ℋ', I: 'ℐ', L: 'ℒ', M: 'ℳ', R: 'ℛ', e: 'ℯ', g: 'ℊ', o: 'ℴ' },
27  fraktur: { C: 'ℭ', H: 'ℌ', I: 'ℑ', R: 'ℜ', Z: 'ℨ' },
28}
29
30const SMALL_CAPS = 'ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘǫʀꜱᴛᴜᴠᴡxʏᴢ'
31
32export function styleText(text: string, font: FontName): string {
33  if (font === 'smallcaps') {
34    return [...text].map(c => (/[a-z]/i.test(c) ? [...SMALL_CAPS][c.toLowerCase().charCodeAt(0) - 97] : c)).join('')
35  }
36
37  const block = BLOCKS[font]
38
39  if (block === undefined) {
40    return text
41  }
42
43  const holes = HOLES[font] ?? {}
44
45  return [...text]
46    .map(c => {
47      if (holes[c] !== undefined) {
48        return holes[c]
49      }
50
51      const code = c.charCodeAt(0)
52
53      if (c >= 'A' && c <= 'Z') {
54        return String.fromCodePoint(block[0] + code - 65)
55      }
56
57      if (c >= 'a' && c <= 'z') {
58        return String.fromCodePoint(block[1] + code - 97)
59      }
60
61      if (c >= '0' && c <= '9' && block[2] !== 0) {
62        return String.fromCodePoint(block[2] + code - 48)
63      }
64
65      return c
66    })
67    .join('')
68}
69
70const hex = (h: string): [number, number, number] => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)) as [number, number, number]
71const toHex = (rgb: number[]): string => `#${rgb.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
72
73// One color per character, interpolated across the stops left to right.
74export function gradient(text: string, colors: readonly string[]): { ch: string; color: string }[] {
75  const chars = [...text]
76  const stops = colors.map(hex)
77
78  if (stops.length === 0) {
79    return chars.map(ch => ({ ch, color: '#ffffff' }))
80  }
81
82  return chars.map((ch, i) => {
83    const t = chars.length <= 1 ? 0 : (i / (chars.length - 1)) * (stops.length - 1)
84    const lo = Math.min(Math.floor(t), stops.length - 1)
85    const hi = Math.min(lo + 1, stops.length - 1)
86    const f = t - lo
87
88    return { ch, color: toHex(stops[lo].map((v, k) => v + (stops[hi][k] - v) * f)) }
89  })
90}
91
92// Fun defaults, picked by a hash of the song so the same song always opens with the same look.
93export const PALETTES: readonly { name: string; colors: string[] }[] = [
94  { name: 'synthwave', colors: ['#ff2a6d', '#d16ba5', '#05d9e8'] },
95  { name: 'sunset', colors: ['#ff7e5f', '#feb47b', '#ffd86f'] },
96  { name: 'ocean', colors: ['#00c6ff', '#4f8bff', '#a18cff'] },
97  { name: 'aurora', colors: ['#00f5a0', '#00d9f5', '#a06bff'] },
98  { name: 'ember', colors: ['#f12711', '#f5af19', '#ffe259'] },
99  { name: 'bubblegum', colors: ['#ff9a9e', '#f6a6d8', '#a18cd1'] },
100  { name: 'toxic', colors: ['#b6ff00', '#00ff9c', '#00c3ff'] },
101  { name: 'gold', colors: ['#f7971e', '#ffd200', '#fff6b7'] },
102]
103
104export function hash(s: string): number {
105  let h = 0x811c9dc5
106
107  for (let i = 0; i < s.length; i++) {
108    h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
109  }
110
111  return h >>> 0
112}
113
114export function defaultTheme(songKey: string): Theme {
115  const h = hash(songKey)
116  const palette = PALETTES[h % PALETTES.length]
117  const fonts: FontName[] = ['bold', 'sans', 'bolditalic', 'double', 'smallcaps', 'script']
118
119  return { font: fonts[(h >>> 8) % fonts.length], colors: palette.colors, mood: palette.name }
120}
121
122export const STYLE_SYSTEM = 'You style a now-playing line in a dark terminal. Reply with one JSON object and nothing else.'
123
124export function stylePrompt(artist: string, title: string): string {
125  return [
126    `Song: "${artist} - ${title}".`,
127    `Pick a letter style from: ${FONTS.join(', ')}.`,
128    'Pick 3 hex colors for a left-to-right gradient that fit the song: its mood, era and genre. All must read well on a dark background.',
129    'Give a mood of at most 3 words.',
130    'Reply exactly: {"font":"...","colors":["#rrggbb","#rrggbb","#rrggbb"],"mood":"..."}',
131  ].join('\n')
132}
133
134// Accept only what the band can draw; anything else falls back to the default.
135export function parseTheme(text: string): Theme | null {
136  const match = text.match(/\{[\s\S]*\}/)
137
138  if (match === null) {
139    return null
140  }
141
142  try {
143    const raw = JSON.parse(match[0]) as { font?: unknown; colors?: unknown; mood?: unknown }
144    const colors = Array.isArray(raw.colors) ? raw.colors.filter(c => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c)) : []
145
146    if (!FONTS.includes(raw.font as FontName) || colors.length < 2) {
147      return null
148    }
149
150    return { font: raw.font as FontName, colors: colors.slice(0, 4), mood: String(raw.mood ?? '').slice(0, 24) }
151  } catch {
152    return null
153  }
154}
155
156// ---- The song key and the Sanduhr bridge -------------------------------------------------------
157// One key for a song in every app: the same rule as Sanduhr's Core (`SongKey`). Sanduhr writes its
158// look for the playing song to ~/.sanduhr/now-playing-look.json under this key, and reads the mod's
159// picks back from now-playing-look-offer.json. Pure helpers, no `$`.
160
161const REMASTER_MARK = / - remaster.*$| \(remaster[^)]*\)$/
162
163// Lowercase each scalar on its own (U+0130 stays as written), collapse whitespace, drop one trailing remaster mark.
164export function normalize(s: string): string {
165  const lowered = [...s].map(c => (c === 'İ' ? c : c.toLowerCase())).join('')
166  const collapsed = lowered.replace(/\s+/g, ' ').trim()
167
168  return collapsed.replace(REMASTER_MARK, '').trim()
169}
170
171function utf8(text: string): number[] {
172  const out: number[] = []
173
174  for (const c of text) {
175    const cp = c.codePointAt(0) as number
176
177    if (cp < 0x80) {
178      out.push(cp)
179    } else if (cp < 0x800) {
180      out.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f))
181    } else if (cp < 0x10000) {
182      // A lone surrogate becomes U+FFFD, as TextEncoder does.
183      const unit = cp >= 0xd800 && cp <= 0xdfff ? 0xfffd : cp
184      out.push(0xe0 | (unit >> 12), 0x80 | ((unit >> 6) & 0x3f), 0x80 | (unit & 0x3f))
185    } else {
186      out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f))
187    }
188  }
189
190  return out
191}
192
193// FNV-1a 64-bit over the UTF-8 bytes of `text`, as 16 lowercase hex digits.
194export function fnv1a64(text: string): string {
195  let h = 0xcbf29ce484222325n
196
197  for (const b of utf8(text)) {
198    h = BigInt.asUintN(64, (h ^ BigInt(b)) * 0x100000001b3n)
199  }
200
201  return h.toString(16).padStart(16, '0')
202}
203
204// The song's key, or null when the title is empty once normalized (the artist may be empty).
205export function songKey(title: string, artist: string): string | null {
206  const t = normalize(title)
207
208  return t === '' ? null : fnv1a64(`${t}\n${normalize(artist)}`)
209}
210
211// Sanduhr draws five letter styles. The mod's other fonts have no counterpart there.
212export type SanduhrStyle = 'plain' | 'bold' | 'italic' | 'bolditalic' | 'smallcaps'
213
214export const SANDUHR_STYLES: readonly SanduhrStyle[] = ['plain', 'bold', 'italic', 'bolditalic', 'smallcaps']
215
216// Sanduhr's style as the mod's font: the five words are the mod's own, same spelling.
217export function fontFromSanduhr(style: string): FontName | null {
218  return (SANDUHR_STYLES as readonly string[]).includes(style) ? (style as FontName) : null
219}
220
221// The mod's font in Sanduhr's vocabulary; sans, mono, double, script and fraktur offer as plain.
222export function sanduhrStyleOf(font: FontName): SanduhrStyle {
223  return (SANDUHR_STYLES as readonly string[]).includes(font) ? (font as SanduhrStyle) : 'plain'
224}
225
226export type SanduhrLook = { key: string; theme: Theme; source: 'saved' | 'seeded' }
227
228const HEX6 = /^#[0-9a-f]{6}$/i
229
230// ~/.sanduhr/now-playing-look.json: `{schema_version:1,key,look:{ink,style,mood},source}`. Null for anything else.
231export function parseSanduhrLook(text: string): SanduhrLook | null {
232  try {
233    const raw = JSON.parse(text) as { schema_version?: unknown; key?: unknown; source?: unknown; look?: { ink?: unknown; style?: unknown; mood?: unknown } }
234    const look = raw.look
235
236    if (raw.schema_version !== 1 || typeof raw.key !== 'string' || !/^[0-9a-f]{16}$/.test(raw.key) || (raw.source !== 'saved' && raw.source !== 'seeded')) {
237      return null
238    }
239
240    if (look === null || typeof look !== 'object' || !Array.isArray(look.ink) || look.ink.length < 1 || look.ink.length > 4) {
241      return null
242    }
243
244    if (!look.ink.every(c => typeof c === 'string' && HEX6.test(c)) || typeof look.style !== 'string') {
245      return null
246    }
247
248    const font = fontFromSanduhr(look.style)
249
250    if (font === null) {
251      return null
252    }
253
254    const mood = typeof look.mood === 'string' ? look.mood.slice(0, 40) : ''
255
256    return { key: raw.key, theme: { font, colors: (look.ink as string[]).map(c => c.toLowerCase()), mood }, source: raw.source }
257  } catch {
258    return null
259  }
260}
261
262// ~/.sanduhr/now-playing-look-offer.json, the mod's pick for a song Sanduhr has no saved look for.
263export function offerJson(key: string, title: string, artist: string, theme: Theme): string {
264  return JSON.stringify({
265    schema_version: 1,
266    key,
267    title,
268    artist,
269    look: { ink: theme.colors.slice(0, 4).map(c => c.toLowerCase()), style: sanduhrStyleOf(theme.font), mood: theme.mood.slice(0, 40) },
270  })
271}
272
273// Where Sanduhr keeps its files for the mod, under the Windows profile folder. Null off Windows.
274export function sanduhrFiles(profile: string | undefined): { dir: string; look: string; offer: string } | null {
275  if (!profile) {
276    return null
277  }
278
279  const dir = `${profile.replace(/[\\/]+$/, '')}\\.sanduhr`
280
281  return { dir, look: `${dir}\\now-playing-look.json`, offer: `${dir}\\now-playing-look-offer.json` }
282}
283
hooks/track.ts 75 lines
1// Pure helpers for the now-playing band. No `$` in here.
2
3export type Track = {
4  app: string
5  status: string
6  artist: string
7  title: string
8  posMs: number
9  endMs: number
10  stampMs: number
11}
12
13// One JSON line from the watcher. `{}` or junk means nothing is playing.
14export function parseLine(line: string): Track | null {
15  try {
16    const raw = JSON.parse(line) as Partial<Track>
17
18    if (typeof raw.title !== 'string' || raw.title === '') {
19      return null
20    }
21
22    return {
23      app: String(raw.app ?? ''),
24      status: String(raw.status ?? ''),
25      artist: String(raw.artist ?? ''),
26      title: raw.title,
27      posMs: Number(raw.posMs) || 0,
28      endMs: Number(raw.endMs) || 0,
29      stampMs: Number(raw.stampMs) || 0,
30    }
31  } catch {
32    return null
33  }
34}
35
36// The player reports position at a moment, not continuously. While playing, add the time since.
37export function livePosition(track: Track, nowMs: number): number {
38  const drift = track.status === 'Playing' && track.stampMs > 0 ? Math.max(0, nowMs - track.stampMs) : 0
39  const pos = track.posMs + drift
40
41  return track.endMs > 0 ? Math.min(pos, track.endMs) : pos
42}
43
44export function clock(ms: number): string {
45  const s = Math.max(0, Math.floor(ms / 1000))
46
47  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
48}
49
50export function bar(posMs: number, endMs: number, width = 20): string {
51  if (endMs <= 0) {
52    return ''
53  }
54
55  const at = Math.min(width - 1, Math.floor((posMs / endMs) * width))
56
57  return `${'━'.repeat(at)}●${'─'.repeat(width - 1 - at)}`
58}
59
60// YouTube titles carry noise the band doesn't need.
61export function cleanTitle(title: string): string {
62  return title
63    .replace(/\s*[([](official|lyrics?|audio|video|music video|visualizer|hd|4k|remaster(ed)?)[^)\]]*[)\]]/gi, '')
64    .trim()
65}
66
67export function bandLine(track: Track, nowMs: number): { label: string; meter: string } {
68  const who = track.artist ? `${track.artist} - ` : ''
69  const mark = track.status === 'Playing' ? '♪' : '❚❚'
70  const pos = livePosition(track, nowMs)
71  const meter = track.endMs > 0 ? `${bar(pos, track.endMs)}  ${clock(pos)} / ${clock(track.endMs)}` : ''
72
73  return { label: `${mark} ${who}${cleanTitle(track.title)}`, meter }
74}
75
hooks/watcher.ts 179 lines
1// Windows PowerShell 5.1 scripts over the system media session (the feed behind the Windows
2// media flyout), so Chrome's YouTube Music tab shows up without any extension or adapter.
3// Single quotes only, so each script survives being passed as one argv entry.
4
5const PREAMBLE = [
6  'Add-Type -AssemblyName System.Runtime.WindowsRuntime',
7  '$null = [Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager,Windows.Media.Control,ContentType=WindowsRuntime]',
8  "$asTask = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]",
9  'function Await($op, $t) { $m = $asTask.MakeGenericMethod($t); $x = $m.Invoke($null, @($op)); $x.Wait(-1) | Out-Null; $x.Result }',
10  '$mgr = Await ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager])',
11]
12
13// Prints the current session as one JSON line every 2s; `{}` when nothing plays.
14const WATCH = [
15  ...PREAMBLE,
16  'while ($true) {',
17  "  $line = '{}'",
18  '  try {',
19  '    $s = $mgr.GetCurrentSession()',
20  '    if ($null -ne $s) {',
21  '      $p = Await ($s.TryGetMediaPropertiesAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionMediaProperties])',
22  '      $t = $s.GetTimelineProperties()',
23  '      $line = [pscustomobject]@{ app = $s.SourceAppUserModelId; status = [string]$s.GetPlaybackInfo().PlaybackStatus; artist = $p.Artist; title = $p.Title; posMs = [long]$t.Position.TotalMilliseconds; endMs = [long]$t.EndTime.TotalMilliseconds; stampMs = $t.LastUpdatedTime.ToUnixTimeMilliseconds() } | ConvertTo-Json -Compress',
24  '    }',
25  '  } catch { }',
26  '  [Console]::Out.WriteLine($line)',
27  '  [Console]::Out.Flush()',
28  '  Start-Sleep -Seconds 2',
29  '}',
30]
31
32export type Control = 'toggle' | 'next' | 'prev'
33
34const CALLS: Record<Control, string> = {
35  toggle: 'TryTogglePlayPauseAsync',
36  next: 'TrySkipNextAsync',
37  prev: 'TrySkipPreviousAsync',
38}
39
40const PS = ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']
41
42export const WATCHER_ARGV: readonly string[] = [...PS, WATCH.join('\n')]
43
44// Prints `ok` when the player accepted the command, `no` when it refused, `none` with no session.
45export function controlArgv(action: Control): string[] {
46  const script = [
47    ...PREAMBLE,
48    '$s = $mgr.GetCurrentSession()',
49    "if ($null -eq $s) { 'none'; return }",
50    `$ok = Await ($s.${CALLS[action]}()) ([bool])`,
51    "if ($ok) { 'ok' } else { 'no' }",
52  ]
53
54  return [...PS, script.join('\n')]
55}
56
57// macOS: Sanduhr's bundled mediaremote-adapter when Sanduhr is installed (every app that
58// publishes now playing, browsers included), else AppleScript to Music and Spotify. Both print the
59// Windows watcher's line, so the band reads them the same way.
60
61// Where Sanduhr.app keeps the adapter, under its Contents folder.
62export type Adapter = { script: string; framework: string }
63
64export function adapterIn(contents: string): Adapter {
65  return {
66    script: `${contents}/Resources/NowPlaying/mediaremote-adapter.pl`,
67    framework: `${contents}/Frameworks/MediaRemoteAdapter.framework`,
68  }
69}
70
71// Sanduhr.app's Contents folders to look in, in order.
72export function sanduhrContents(home: string | undefined): string[] {
73  const roots = ['/Applications/Sanduhr.app/Contents']
74
75  return home ? [...roots, `${home}/Applications/Sanduhr.app/Contents`] : roots
76}
77
78// The adapter's `get` once every 2s, turned into the watcher's line. MediaRemote answers Apple-signed
79// hosts only, so the adapter runs under /usr/bin/perl. The paths come in as arguments, never as code.
80const ADAPTER_WATCH = [
81  'use strict; use JSON::PP; $| = 1;',
82  'my ($pl, $fw) = @ARGV; my $j = JSON::PP->new->canonical;',
83  'sub ms { my ($us, $s) = @_; defined $us ? int($us / 1000) : defined $s ? int($s * 1000) : 0 }',
84  'while (1) {',
85  "  my $line = '{}';",
86  "  if (open(my $fh, '-|', '/usr/bin/perl', $pl, $fw, 'get', '--no-artwork', '--micros')) {",
87  '    my $out = do { local $/; <$fh> }; close $fh;',
88  '    my $d = eval { JSON::PP->new->allow_nonref->decode($out) };',
89  "    if (ref $d eq 'HASH' && defined $d->{title} && length $d->{title}) {",
90  "      $line = $j->encode({ app => $d->{bundleIdentifier} // '', status => ($d->{playing} ? 'Playing' : 'Paused'),",
91  "        artist => $d->{artist} // '', title => $d->{title},",
92  '        posMs => ms($d->{elapsedTimeMicros}, $d->{elapsedTime}), endMs => ms($d->{durationMicros}, $d->{duration}),',
93  '        stampMs => ms($d->{timestampEpochMicros}, time) });',
94  '    }',
95  '  }',
96  '  print "$line\\n"; sleep 2;',
97  '}',
98]
99
100// MRCommand ids the adapter sends: kMRTogglePlayPause, kMRNextTrack, kMRPreviousTrack.
101const MR: Record<Control, string> = { toggle: '2', next: '4', prev: '5' }
102
103const ADAPTER_CONTROL = [
104  'use strict;',
105  'my ($pl, $fw, $cmd) = @ARGV;',
106  "open(my $fh, '-|', '/usr/bin/perl', $pl, $fw, 'get', '--no-artwork') or do { print \"no\\n\"; exit };",
107  'my $out = do { local $/; <$fh> }; close $fh;',
108  'if ($out !~ /"title"/) { print "none\\n"; exit }',
109  "system('/usr/bin/perl', $pl, $fw, 'send', $cmd);",
110  'print $? == 0 ? "ok\\n" : "no\\n";',
111]
112
113// Without Sanduhr: Music or Spotify, whichever is playing (else paused), read every 2s. Never
114// launches either app: `running()` asks without starting it. macOS asks once to allow the control.
115const SCRIPTED = [
116  "ObjC.import('Foundation')",
117  "function say(s) { $.NSFileHandle.fileHandleWithStandardOutput.writeData($(s + '\\n').dataUsingEncoding($.NSUTF8StringEncoding)) }",
118  'function look(name) {',
119  '  try {',
120  '    const a = Application(name)',
121  '    if (!a.running()) return null',
122  '    const st = a.playerState()',
123  "    if (st !== 'playing' && st !== 'paused') return null",
124  '    const t = a.currentTrack()',
125  "    const end = name === 'Spotify' ? t.duration() : t.duration() * 1000",
126  "    return { app: name, status: st === 'playing' ? 'Playing' : 'Paused', artist: t.artist(), title: t.name(),",
127  '      posMs: Math.round(a.playerPosition() * 1000), endMs: Math.round(end), stampMs: Date.now() }',
128  '  } catch (e) { return null }',
129  '}',
130  "function pick() { const all = ['Music', 'Spotify'].map(look).filter(Boolean); return all.find(t => t.status === 'Playing') || all[0] || null }",
131]
132
133const SCRIPTED_WATCH = [...SCRIPTED, 'while (true) { const t = pick(); say(t ? JSON.stringify(t) : \'{}\'); delay(2) }']
134
135const SCRIPTED_CALLS: Record<Control, string> = { toggle: 'playpause', next: 'nextTrack', prev: 'previousTrack' }
136
137function scriptedControl(action: Control): string[] {
138  return [
139    ...SCRIPTED,
140    'const t = pick()',
141    `if (!t) { say('none') } else { try { Application(t.app).${SCRIPTED_CALLS[action]}(); say('ok') } catch (e) { say('no') } }`,
142  ]
143}
144
145const OSA = ['/usr/bin/osascript', '-l', 'JavaScript', '-e']
146
147// The Mac watcher's argv: the adapter's when found, else the scripted one.
148export function macWatcherArgv(adapter: Adapter | null): string[] {
149  return adapter
150    ? ['/usr/bin/perl', '-e', ADAPTER_WATCH.join('\n'), adapter.script, adapter.framework]
151    : [...OSA, SCRIPTED_WATCH.join('\n')]
152}
153
154// Prints `ok`, `no` or `none`, as the Windows control does.
155export function macControlArgv(action: Control, adapter: Adapter | null): string[] {
156  return adapter
157    ? ['/usr/bin/perl', '-e', ADAPTER_CONTROL.join('\n'), adapter.script, adapter.framework, MR[action]]
158    : [...OSA, scriptedControl(action).join('\n')]
159}
160
161// What `/np <word>` means. Undefined for a word it doesn't know.
162export function parseControl(word: string): Control | undefined {
163  const w = word.trim().toLowerCase()
164
165  if (['', 'pause', 'play', 'toggle', 'pp'].includes(w)) {
166    return 'toggle'
167  }
168
169  if (['next', 'skip', 'n', '>'].includes(w)) {
170    return 'next'
171  }
172
173  if (['prev', 'previous', 'back', 'p', '<'].includes(w)) {
174    return 'prev'
175  }
176
177  return undefined
178}
179
types/index.d.ts 35 lines
1export type NowTrack = {
2  app: string
3  status: string
4  artist: string
5  title: string
6  posMs: number
7  endMs: number
8  stampMs: number
9}
10
11export type NowLyrics = {
12  key: string
13  lines: { ms: number; text: string }[]
14}
15
16export type NowTheme = {
17  key: string
18  font: string
19  colors: string[]
20  mood: string
21  source: string
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'now-playing': {
27      track: NowTrack | null
28      lyrics: NowLyrics | null
29      theme: NowTheme | null
30      controlsUntil: number
31      shineAt: number
32    }
33  }
34}
35