SLOPSHOPPER

yt-music

YT Music in Chrome above the prompt: the track, its time, and play/pause, previous, next and seek controls

newspinnerprocesstimer
v0.1.0no licenseupdated 2026-10-12cybertron288/claude-mods/yt-music
A shopper browsing a rack in a slop shop
README

claude-mods

My personal Claude Code mods. Each one is tuned for my own setup and taste: the macOS + Google Chrome stack, a gruvbox palette and the layout of my terminal. They are shared as they are. Use them, fork them, but expect to change them to fit your setup.

One directory per mod.

ModDescription
yt-musicYT Music in Chrome under the prompt: track, time, play/pause, previous and next
session-infoUsage meters, model, effort, context window and git branch in one line under the prompt

Assumptions

  • yt-music: macOS only. It drives Google Chrome through osascript, so Chrome needs "Allow JavaScript from Apple Events" turned on (View → Developer). It reads the first tab open on music.youtube.com.
  • session-info: runs git in the session's working directory. Colors are hard-coded gruvbox.

Install

/plugin marketplace add cybertron288/claude-mods
/plugin install yt-music@ravi-mods
/plugin install session-info@ravi-mods
Source 2 files
hooks/register.tsx 155 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Track } from '../types'
5
6const trackAtom = atom({ plugin: 'yt-music', key: 'track' } as const, null as Track | null)
7
8const POLL_MS = 2000
9const SEEK_SECONDS = 10
10
11// Runs `js` in the first Chrome tab on music.youtube.com and answers what it returns,
12// '' when Chrome holds no such tab. The script reaches osascript as an argument, so it
13// needs no AppleScript escaping.
14const RUN_IN_TAB = [
15  'on run argv',
16  'tell application "Google Chrome"',
17  'repeat with w in windows',
18  'repeat with t in tabs of w',
19  'if URL of t contains "music.youtube.com" then return execute t javascript (item 1 of argv)',
20  'end repeat',
21  'end repeat',
22  'end tell',
23  'return ""',
24  'end run',
25]
26
27const READ_JS = `(() => {
28  const v = document.querySelector('video'), m = navigator.mediaSession.metadata
29  if (!v || !m) return ''
30  return JSON.stringify({ title: m.title, artist: m.artist, isPaused: v.paused, seconds: v.currentTime, duration: v.duration || 0 })
31})()`
32
33// Presses the player's own control by its label: the new player (ytmusic-wiz-player-controls)
34// first, the older player bar after. Carousels on the page carry Next and Previous too.
35const pressControl = (label: string): string =>
36  `document.querySelector('ytmusic-wiz-player-controls [aria-label="${label}"], ytmusic-player-bar [aria-label="${label}"]')?.click()`
37
38const ACTIONS = {
39  toggle: `(v => v.paused ? v.play() : v.pause())(document.querySelector('video'))`,
40  next: pressControl('Next'),
41  previous: pressControl('Previous'),
42  back: `(v => { v.currentTime = Math.max(0, v.currentTime - ${SEEK_SECONDS}) })(document.querySelector('video'))`,
43  forward: `(v => { v.currentTime = v.currentTime + ${SEEK_SECONDS} })(document.querySelector('video'))`,
44} as const
45
46// Single-width glyphs: the emoji media symbols (⏮ ⏸ ⏭) draw two columns wide in most
47// terminals, which shifts the spacing and the hover highlight off the button.
48const CONTROLS: readonly (readonly [keyof typeof ACTIONS, string])[] = [
49  ['previous', '|◂'],
50  ['back', '◂◂'],
51  ['toggle', '▸'],
52  ['forward', '▸▸'],
53  ['next', '▸|'],
54]
55
56const clock = (seconds: number): string => {
57  const whole = Math.max(0, Math.floor(seconds))
58
59  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`
60}
61
62// Gruvbox, to match the skin.
63const COLORS = { note: '#fb4934', title: '#fabd2f', played: '#b8bb26', muted: '#928374' }
64
65const filledOf = (seconds: number, duration: number, width: number): number =>
66  duration > 0 ? Math.min(width, Math.round((seconds / duration) * width)) : 0
67
68const played = (seconds: number, duration: number, width = 16): string => '━'.repeat(filledOf(seconds, duration, width))
69
70const left = (seconds: number, duration: number, width = 16): string => '─'.repeat(width - filledOf(seconds, duration, width))
71
72async function runInTab($: EngineInterface, js: string): Promise<string | null> {
73  // Asking Chrome while it is closed would launch it.
74  const chrome = await $.process.run(['pgrep', '-x', 'Google Chrome']).catch(() => null)
75
76  if (chrome === null || chrome.exitCode !== 0) {
77    return null
78  }
79
80  const ran = await $.process.run(['osascript', ...RUN_IN_TAB.flatMap(line => ['-e', line]), js]).catch(() => null)
81
82  return ran !== null && ran.exitCode === 0 ? ran.stdout.trim() : null
83}
84
85async function refresh($: EngineInterface): Promise<void> {
86  const out = await runInTab($, READ_JS)
87  let track: Track | null = null
88
89  if (out !== null && out !== '' && out !== 'missing value') {
90    try {
91      track = JSON.parse(out) as Track
92    } catch {
93      track = null
94    }
95  }
96
97  if (JSON.stringify(await read($, trackAtom)) !== JSON.stringify(track)) {
98    await update($, trackAtom, () => track)
99  }
100}
101
102async function press($: EngineInterface, action: keyof typeof ACTIONS): Promise<void> {
103  await runInTab($, ACTIONS[action])
104  await refresh($)
105}
106
107let poller: { cancel: () => void } | null = null
108
109export const register: Register = on => {
110  on('session.start', async ($, e, next) => {
111    const result = await next(e)
112    poller?.cancel()
113    poller = $.clock.every(POLL_MS, () => void refresh($))
114    await refresh($)
115
116    return result
117  })
118
119  on('session.end', ($, e, next) => {
120    poller?.cancel()
121    poller = null
122
123    return next(e)
124  })
125
126  // Under the prompt, after the engine's own hint line.
127  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
128    const track = await read($, trackAtom)
129    const theirs = await next(e)
130
131    if (track === null) {
132      return theirs
133    }
134
135    const { Box, Button, Text } = $.ui.resolve(e)
136
137    return (
138      <Box flexDirection="column">
139        {theirs}
140        <Box>
141          <Text color={COLORS.note}>♪ </Text>
142          <Text bold color={COLORS.title}>{track.title}</Text>
143          <Text color={COLORS.muted}>{` - ${track.artist}  `}</Text>
144          {CONTROLS.map(([action, label]) => (
145            <Button plain key={action} label={` ${action === 'toggle' && !track.isPaused ? '‖' : label} `} onPress={() => press($, action)} />
146          ))}
147          <Text color={COLORS.muted}>{`  ${clock(track.seconds)} `}</Text>
148          <Text color={COLORS.played}>{played(track.seconds, track.duration)}</Text>
149          <Text color={COLORS.muted}>{`${left(track.seconds, track.duration)} ${clock(track.duration)}`}</Text>
150        </Box>
151      </Box>
152    )
153  })
154}
155
types/index.d.ts 8 lines
1export type Track = { title: string; artist: string; isPaused: boolean; seconds: number; duration: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'yt-music': { track: Track | null }
6  }
7}
8