Files
app/frontend/src/components/Dashboard/PlayerWidget.tsx
T
stroblme 67c35093e6 Add a player widget, and let a slider be drawn as a fader
The player is the one tile that both reads and publishes, so it has two
bindings: it shows a `record` describing what is playing — title, artist,
album, status, and position and duration in seconds — and publishes transport
words back to one `str` message (`toggle`, `next`, `prev`, `seek:<seconds>`).
Those are a streamer's own vocabulary rather than this app's, which is what
lets one tile drive whatever is on the other end.

The position counts forward in the browser between readings, so the bar moves
at one second while the device behind it is polled at whatever rate suits it;
every reading that arrives is taken as the truth and the count restarts there.
That is also why this is one record rather than five messages — a tile drawn
from five would redraw itself five times, and show a new title against the old
duration in between.

Being both is why `INPUT_WIDGETS` does not gain it: what that set means is "the
message this widget publishes is its only binding", which is exactly what a
player is not. Its reading is checked the usual way and its `target` separately.

The fader beside it needed nothing new. `ui/core` has had `orientation` on the
slider all along and all three looks draw it; only the widget never passed it,
so a volume control — the one thing reached for without looking, where up is
louder — could not be a column. Now it can, and the tile's height is the track.
2026-08-30 14:17:16 +02:00

150 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<seconds>`), 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 <p className="text-muted-foreground">Pick a message.</p>
if (!target) {
return (
<p className="text-muted-foreground">Pick a message to publish to.</p>
)
}
if (!status) return <p className="text-muted-foreground">Nothing yet.</p>
const title =
text(track.title) || (status === "off" ? "Off" : "Nothing playing")
const artist = text(track.artist)
return (
<div className="flex h-full min-w-0 flex-col justify-center gap-2">
{pulse}
<div className="min-w-0">
<p className="truncate font-medium">{title}</p>
{/* Held even when empty, or the row above jumps as tracks change. */}
<p className="truncate text-muted-foreground">{artist || " "}</p>
</div>
<div className="flex items-center gap-2 tabular-nums text-muted-foreground">
<span className="shrink-0">{clock(at)}</span>
<div className="min-w-0 flex-1">
<Slider
value={at}
min={0}
// A stream has no length; the bar then has nothing to say and is
// drawn empty rather than full.
max={duration || 1}
step={1}
ticks={false}
label="Position"
disabled={locked || duration === 0}
onCommit={(seconds) => send(`seek:${Math.round(seconds)}`)}
/>
</div>
<span className="shrink-0">{clock(duration)}</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="tonal"
label="Previous"
disabled={locked}
onClick={() => send("prev")}
>
<SkipBack className="mx-auto size-4" aria-hidden />
</Button>
<Button
variant={playing ? "filled" : "tonal"}
pressed={playing}
label={playing ? "Pause" : "Play"}
disabled={locked}
onClick={() => send("toggle")}
>
{playing ? (
<Pause className="mx-auto size-4" aria-hidden />
) : (
<Play className="mx-auto size-4" aria-hidden />
)}
</Button>
<Button
variant="tonal"
label="Next"
disabled={locked}
onClick={() => send("next")}
>
<SkipForward className="mx-auto size-4" aria-hidden />
</Button>
</div>
</div>
)
}