SLOPSHOPPER

keep-now-playing

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

newbandcommandnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keep-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 › /keep-music ⎿ keep-now-playing: not connected: /keep-music connect <url> <token> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

keep now playing for Claude Code

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.

Install

/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.

Use

  • /keep-music or /keep-music pause: play / pause
  • /keep-music next, /keep-music prev
  • /keep-music off: disconnect

The player server needs ONE_SESSION=on (the default) for the buttons.

Source 2 files
hooks/register.tsx 137 lines
1import 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};
137
types/index.d.ts 16 lines
1/** 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