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

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.
| Mod | Description |
|---|---|
| yt-music | YT Music in Chrome under the prompt: track, time, play/pause, previous and next |
| session-info | Usage meters, model, effort, context window and git branch in one line under the prompt |
osascript, so Chrome needs "Allow JavaScript from Apple Events" turned on (View → Developer). It reads the first tab open on music.youtube.com.git in the session's working directory. Colors are hard-coded gruvbox./plugin marketplace add cybertron288/claude-mods
/plugin install yt-music@ravi-mods
/plugin install session-info@ravi-modshooks/register.tsx 155 lines1import { 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}
155types/index.d.ts 8 lines1export 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