"use client"; // Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13). // Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only // the Discord servers linked to this node's org and the voice channels the // DRB bot reported in them; a pick becomes a radio channel pinned to this node // (desired state — the node reconciles and reports back, shown per row). // Layout follows design-canvas/discord-system-mockup.html "Radio channels". import { useCallback, useEffect, useMemo, useState } from "react"; import { c2api } from "@/lib/c2api"; import { parseC2Error, type DiscordGuildOption, type DiscordInvite, type DiscordJoinOptions, type DiscordStreamView, type StreamState, } from "@/lib/discordPicker"; const POLL_MS = 10_000; const STATE: Record = { pending: { label: "on → waiting for node", dot: "bg-sev-moderate" }, connecting: { label: "on → connecting", dot: "bg-sev-moderate" }, connected: { label: "on → connected", dot: "bg-green-500" }, error: { label: "on → error", dot: "bg-sev-major" }, stopping: { label: "off → stopping", dot: "bg-ink-muted" }, stopped: { label: "off", dot: "bg-ink-muted" }, node_offline: { label: "on → node offline", dot: "bg-sev-major" }, paused_locally: { label: "on → paused on node", dot: "bg-sev-moderate" }, }; function StatePill({ s }: { s: DiscordStreamView }) { const st = STATE[s.actual?.state ?? "pending"] ?? STATE.pending; return ( {st.label} ); } function InviteLinks({ invite }: { invite: DiscordInvite[] }) { if (!invite.length) return null; return ( {invite.map((i) => ( Invite {i.name} ))} ); } function modeLabel(mode: DiscordStreamView["mode"]) { return mode === "persistent" ? "persistent" : "ad-hoc"; } // --------------------------------------------------------------------------- // Picker // --------------------------------------------------------------------------- function DiscordPicker({ nodeId, options, onClose, onStarted, }: { nodeId: string; options: DiscordJoinOptions; onClose: () => void; onStarted: () => void; }) { const usable = options.guilds; const [guildId, setGuildId] = useState(usable.length === 1 ? usable[0].guild_id : ""); const [channelId, setChannelId] = useState(""); const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc"); const [sending, setSending] = useState(false); const [error, setError] = useState<{ message: string; invite: DiscordInvite[] } | null>(null); const guild: DiscordGuildOption | undefined = usable.find((g) => g.guild_id === guildId); const channel = guild?.voice_channels.find((c) => c.channel_id === channelId); const atNodeCap = options.limits.node_used >= options.limits.node_max; const channelOnHere = channel?.stream?.node_id === nodeId; const grouped = useMemo(() => { const out: { category: string | null; channels: DiscordGuildOption["voice_channels"] }[] = []; for (const c of guild?.voice_channels ?? []) { const last = out[out.length - 1]; if (last && last.category === c.category) last.channels.push(c); else out.push({ category: c.category, channels: [c] }); } return out; }, [guild]); async function start() { if (!guild || !channel) return; setSending(true); setError(null); try { await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode }); onStarted(); onClose(); } catch (err) { setError(parseC2Error(err)); } finally { setSending(false); } } return (

Add radio channel

Source: {options.system_name ?? "no system"} ·{" "} {options.node_name ?? nodeId}. Audio comes from this node's PulseAudio monitor.

{/* 1 — server */}
Discord server {usable.length === 0 ? (

No Discord server is linked to your organization yet. Add the DRB bot to your Discord server and link it to your organization; it shows up here once the bot reports its channels.

) : (
{usable.map((g) => ( ))}
)}
{/* 2 — voice channel */} {guild && (
Voice channel {!guild.synced ? (

The DRB bot has not reported this server's channels yet. It does when it starts and every 15 minutes — check back shortly.

) : guild.voice_channels.length === 0 ? (

This server has no voice channels the DRB bot can see.

) : (
{grouped.map((grp, gi) => (
{grp.category && (
{grp.category}
)}
{grp.channels.map((c) => { const here = c.stream?.node_id === nodeId; return ( ); })}
))}
)}
)} {/* 3 — mode */} {channel && (
Mode
{( [ ["adhoc", "Ad-hoc", "Stops if this node goes offline or switches system."], ["persistent", "Persistent", `Stays up: if this node drops, DRB moves it to another node on ${options.system_name ?? "this system"}.`], ] as const ).map(([value, label, hint]) => ( ))}
)} {!options.system_id && (

This node is not listening to a system yet. Assign it one before adding a radio channel.

)} {atNodeCap && !channelOnHere && (

This node already carries {options.limits.node_used} of {options.limits.node_max} radio channels. Stop one first.

)} {error && (

{error.message}

)}
); } // --------------------------------------------------------------------------- // Panel // --------------------------------------------------------------------------- export function NodeDiscordRadio({ nodeId }: { nodeId: string }) { const [options, setOptions] = useState(null); const [loadError, setLoadError] = useState(null); const [picking, setPicking] = useState(false); const [busy, setBusy] = useState(null); const [actionError, setActionError] = useState(null); const load = useCallback(async () => { try { setOptions(await c2api.getNodeDiscordOptions(nodeId)); setLoadError(null); } catch (err) { setLoadError(parseC2Error(err).message); } }, [nodeId]); useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === "visible") load(); }, POLL_MS); return () => clearInterval(t); }, [load]); async function stop(streamId?: string) { if (!streamId && !confirm("Stop every Discord radio channel this node carries?")) return; setBusy(streamId ?? "all"); setActionError(null); try { await c2api.stopNodeDiscordStreams(nodeId, streamId); await load(); } catch (err) { setActionError(parseC2Error(err).message); } finally { setBusy(null); } } const streams = options?.streams ?? []; const canControl = options?.can_control ?? false; return (

Discord radio

Voice channels this node streams into. They reconnect by themselves; state is reported by the node. {canControl && ( {streams.length > 1 && ( )} )}
{loadError ? (

Couldn't load Discord radio: {loadError}

) : !options ? (

Loading…

) : streams.length === 0 ? (

Not streaming into any Discord voice channel. {!canControl && " The organization owner or this node's operator can add one."}

) : (
{canControl && {streams.map((s) => ( {canControl && ( )} ))}
Server · channel Mode Desired → actual}
🔊 {s.channel_name ?? s.channel_id}
{s.guild_name ?? s.guild_id}
{modeLabel(s.mode)} {s.actual?.error && (
{s.actual.error}
)}
)} {actionError &&

{actionError}

} {options && !options.node_online && streams.length > 0 && (

This node is offline — its channels resume if it reconnects shortly. If it stays down, ad-hoc ones stop and persistent ones move to another node on the same system.

)} {picking && options && ( setPicking(false)} onStarted={load} /> )}
); }