What your keep-player (Navidrome) plays, with its lyric line and play/pause, above the prompt

What your keep-player plays, above the Claude Code prompt: the song, the artist, the playhead, the synced lyric line, and ⏮ ⏸ ⏭ buttons for whichever of your players is playing it. A song playing in another Subsonic app (Arpeggi on your phone) shows too, without the buttons.
/plugin marketplace add FindMalek/keep-player
/plugin install keep-now-playing@keep-player
Then in the player: settings → sync → now playing in your terminal → connect. It shows one line, /keep-music connect https://your-player <token>: paste it into Claude Code. The token is a sign-in of its own; sign it out under settings → account → sessions.
/keep-music or /keep-music pause: play / pause/keep-music next, /keep-music prev/keep-music off: disconnectThe player server needs ONE_SESSION=on (the default) for the buttons.
hooks/register.tsx 137 lines1import type { EngineInterface, Register } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import type { Now } from "../types";
5
6/*
7 * keep-player's now playing above the prompt: song, artist, playhead, the synced lyric line, and ⏮ ⏯ ⏭ for whichever
8 * of your players is playing. `/keep-music connect <url> <token>` once (the player's settings → sync makes the line).
9 */
10
11const POLL_MS = 2000;
12const BAR = 16;
13
14const now = atom({ plugin: "keep-now-playing", key: "now" } as const, null);
15const error = atom({ plugin: "keep-now-playing", key: "error" } as const, null);
16
17type Link = { url: string; token: string };
18
19const clock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
20
21function bar(position: number, duration: number): string {
22 const filled = Math.max(0, Math.min(BAR, Math.round((position / duration) * BAR)));
23 return "━".repeat(filled) + "─".repeat(BAR - filled);
24}
25
26async function link($: EngineInterface): Promise<Link | null> {
27 const saved = (await $.store.get("link")) as Link | undefined;
28 return saved?.url && saved.token ? saved : null;
29}
30
31async function call($: EngineInterface, path: string, method = "GET") {
32 const target = await link($);
33 if (!target) return null;
34 return $.http.fetch(`${target.url}${path}`, { method, headers: { authorization: `Bearer ${target.token}` } });
35}
36
37async function poll($: EngineInterface): Promise<void> {
38 const response = await call($, "/api/remote/now").catch(() => null);
39 if (!response) return;
40 if (response.status === 401) {
41 await update($, error, () => "signed out: make a new line in the player (settings → sync)");
42 await update($, now, () => null);
43 return;
44 }
45 if (!response.ok) return;
46 await update($, error, () => null);
47 await update($, now, () => JSON.parse(response.text) as Now);
48}
49
50async function control($: EngineInterface, action: "toggle" | "next" | "previous"): Promise<string> {
51 const response = await call($, `/api/remote/${action}`, "POST").catch(() => null);
52 if (!response) return "not connected: /keep-music connect <url> <token>";
53 if (response.status === 409) return "nothing of yours is playing in keep-player (arpeggi can't be controlled from here)";
54 if (!response.ok) return `the player said ${response.status}`;
55 await poll($);
56 return "";
57}
58
59export const register: Register = (on) => {
60 on("session.start", async ($, e, next) => {
61 // Another plugin owning the name must not stop the polling below.
62 await $.command
63 .register({
64 name: "keep-music",
65 description: "keep-player: connect <url> <token> · pause · next · prev · off",
66 })
67 .catch(() => undefined);
68 void poll($);
69 $.clock.every(POLL_MS, () => void poll($));
70 return next(e);
71 });
72
73 on("command.run", { command: "keep-music" }, async ($, e) => {
74 const [verb = "", ...rest] = e.args.trim().split(/\s+/);
75 if (verb === "connect") {
76 const [url, token] = rest;
77 if (!url || !token || !/^https?:\/\//.test(url)) return { text: "usage: /keep-music connect https://your-player <token>" };
78 await $.store.set("link", { url: url.replace(/\/+$/, ""), token });
79 await poll($);
80 return { text: (await read($, error)) ?? `connected to ${url}` };
81 }
82 if (verb === "off") {
83 await $.store.delete("link");
84 await update($, now, () => null);
85 return { text: "disconnected" };
86 }
87 const action =
88 verb === "next"
89 ? "next"
90 : verb === "prev" || verb === "previous"
91 ? "previous"
92 : verb === "" || verb === "pause" || verb === "play"
93 ? "toggle"
94 : null;
95 if (!action) return { text: "usage: /keep-music [pause | next | prev | connect <url> <token> | off]" };
96 const said = await control($, action);
97 return { text: said || undefined };
98 });
99
100 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
101 const playing = await read($, now);
102 const problem = await read($, error);
103 if (e.props.hasSurvey || (!playing?.song && !problem)) return next(e);
104 const { Box, Button, Text } = $.ui.resolve(e);
105 if (!playing?.song) return <Text dimColor>♪ {problem}</Text>;
106 const { song, position, line } = playing;
107 return (
108 <Box flexDirection="column">
109 <Box gap={1}>
110 <Text dimColor={!playing.playing}>{playing.playing ? "♪" : "‖"}</Text>
111 <Text bold>{song.title}</Text>
112 {song.artist ? <Text dimColor>{song.artist}</Text> : null}
113 {position !== null && song.duration ? (
114 <Text dimColor>
115 {clock(position)} {bar(position, song.duration)} {clock(song.duration)}
116 </Text>
117 ) : null}
118 {playing.device ? <Text dimColor>on {playing.device}</Text> : null}
119 {playing.controllable ? (
120 <>
121 <Button key="previous" label="⏮" onPress={() => void control($, "previous")} />
122 <Button key="toggle" label={playing.playing ? "⏸" : "▶"} onPress={() => void control($, "toggle")} />
123 <Button key="next" label="⏭" onPress={() => void control($, "next")} />
124 </>
125 ) : null}
126 </Box>
127 {line ? (
128 <Text italic dimColor>
129 {" "}
130 {line}
131 </Text>
132 ) : null}
133 </Box>
134 );
135 });
136};
137types/index.d.ts 16 lines1/** What `GET /api/remote/now` answers. */
2export type Now = {
3 song: { id: string; title: string; artist?: string; album?: string; duration?: number } | null;
4 position: number | null;
5 playing: boolean;
6 device: string | null;
7 line: string | null;
8 controllable: boolean;
9};
10
11declare module "claude-code" {
12 interface PluginState {
13 "keep-now-playing": { now: Now | null; error: string | null };
14 }
15}
16