import { Pause, Play, SkipBack, SkipForward } from "lucide-react" import { useEffect, useState } from "react" import { useBoundValue } from "./dataContext" import { usePublish } from "./publish" import { useUi } from "./ui" import { config, num, text } from "./ui/core/config" import type { WidgetProps } from "./widgets" /** What a streamer says it is playing. Every field is optional on the wire. */ type Track = { title?: unknown artist?: unknown album?: unknown status?: unknown position?: unknown duration?: unknown } /** m:ss, which is how long a track is written everywhere else. */ function clock(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return "–:––" const whole = Math.floor(seconds) return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}` } /** * Where the track is now, counted here between readings. * * The engine hears from the streamer every few seconds, which is often enough * for what is playing and far too seldom for a bar that is supposed to move. * So the reported position is taken as the truth whenever it arrives and * counted forward locally in between — the same thing every player does, and * the reason this does not need the device polled once a second. */ function usePosition(reported: number, playing: boolean): number { const [position, setPosition] = useState(reported) // Keyed on the reading rather than on a timer: a seek, a skip and a pause // all land here as a new `reported`, and each one is where the count // restarts from. useEffect(() => setPosition(reported), [reported]) useEffect(() => { if (!playing) return const timer = setInterval(() => setPosition((at) => at + 1), 1000) return () => clearInterval(timer) }, [playing]) return position } /** * A streamer's own controls: what is playing, and the four things to do to it. * * One record in and one string out. The reading is a whole track — title, * artist, status, position, duration — because they are one thing, and the * commands are the words the device already understands (`toggle`, `next`, * `prev`, `seek:`), so nothing here has to know which streamer is on * the other end. */ export function PlayerWidget({ widget, dashboard }: WidgetProps) { const { Button, Slider } = useUi() const cfg = config(widget) const message = text(cfg.message) const live = useBoundValue(message || undefined) const { target, send, pulse, locked } = usePublish(widget, dashboard) const track = (live?.value ?? {}) as Track const status = text(track.status) const playing = status === "play" const duration = Math.max(0, num(track.duration, 0)) const position = usePosition(Math.max(0, num(track.position, 0)), playing) const at = Math.min(position, duration || position) if (!message) return

Pick a message.

if (!target) { return (

Pick a message to publish to.

) } if (!status) return

Nothing yet.

const title = text(track.title) || (status === "off" ? "Off" : "Nothing playing") const artist = text(track.artist) return (
{pulse}

{title}

{/* Held even when empty, or the row above jumps as tracks change. */}

{artist || " "}

{clock(at)}
send(`seek:${Math.round(seconds)}`)} />
{clock(duration)}
) }