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.
This commit is contained in:
2026-08-30 14:17:16 +02:00
parent f370601aec
commit 67c35093e6
9 changed files with 302 additions and 27 deletions
+8
View File
@@ -64,6 +64,7 @@ WidgetType = Literal[
"forecast", "forecast",
"clock", "clock",
"media", "media",
"player",
# Input # Input
"button", "button",
"switch", "switch",
@@ -73,6 +74,9 @@ WidgetType = Literal[
"color", "color",
] ]
#: Widgets whose only binding is the message they publish. A player is not one:
#: it publishes transport commands *and* reads what is playing, so its reading
#: is its binding and its ``target`` is checked separately.
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown", "color"} INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown", "color"}
#: What a colour widget puts on the wire, by the format it was configured for. #: What a colour widget puts on the wire, by the format it was configured for.
@@ -105,6 +109,10 @@ WIDGET_DTYPES: dict[str, set[str]] = {
# bound to; a plain artifact is taken as well, since the bytes may be # bound to; a plain artifact is taken as well, since the bytes may be
# anything and the media type on the reference is what says what they are. # anything and the media type on the reference is what says what they are.
"media": {"image", "audio", "video", "artifact"}, "media": {"image", "audio", "video", "artifact"},
# What a streamer says it is playing: title, artist, status, position and
# duration in one reading, because they are one thing and a player drawn
# from five separate messages would redraw itself five times.
"player": {"record"},
# An icon maps weather strings, bool hints and numbers alike, and a clock # An icon maps weather strings, bool hints and numbers alike, and a clock
# binds nothing at all, so neither has a row to be held to. # binds nothing at all, so neither has a row to be held to.
} }
+14
View File
@@ -252,6 +252,20 @@ def test_a_media_widget_binds_media_and_nothing_else():
) )
def test_a_player_reads_a_track_and_publishes_words():
WidgetDef(
id="p",
type="player",
config={"message": "music.track", "dtype": "record", "target": "music.command"},
)
# One reading, not five: a title, a position and a duration arrive together
# or the tile redraws itself a piece at a time.
with pytest.raises(ValueError):
WidgetDef(
id="p", type="player", config={"message": "music.title", "dtype": "str"}
)
def test_a_bar_nests_a_second_number(): def test_a_bar_nests_a_second_number():
WidgetDef( WidgetDef(
id="b", id="b",
+23 -1
View File
@@ -35,6 +35,7 @@ and dragging is off. Picking a widget and editing its settings still works.
| **Forecast** | `list` | a short outlook strip | | **Forecast** | `list` | a short outlook strip |
| **Notification** | `record` | title, body and severity — what an alert channel writes | | **Notification** | `record` | title, body and severity — what an alert channel writes |
| **Media** | `image`, `audio`, `video` | a camera frame, a clip; see *Media tiles* below | | **Media** | `image`, `audio`, `video` | a camera frame, a clip; see *Media tiles* below |
| **Player** | `record` | what a streamer is playing, with its transport; see *Player tiles* below |
| **Clock** | — | the time, in a size a wall can read | | **Clock** | — | the time, in a size a wall can read |
Every widget carries a **title**, and **Show title** decides whether the panel Every widget carries a **title**, and **Show title** decides whether the panel
@@ -48,7 +49,7 @@ screen reader calls its controls, and what a published value is labelled with.
|---|---|---| |---|---|---|
| **Button** | a fixed value | one-shot: run it, open it, reset it | | **Button** | a fixed value | one-shot: run it, open it, reset it |
| **Switch** | `bool` | on/off | | **Switch** | `bool` | on/off |
| **Slider** | `float`, `int` | min, max, step | | **Slider** | `float`, `int` | min, max, step; **Drawn as** makes it a vertical fader |
| **Input** | text or a number | free entry | | **Input** | text or a number | free entry |
| **Selector** | one of a list | a mode, a scene, a preset — as a menu, or as a row of choices with the active one held | | **Selector** | one of a list | a mode, a scene, a preset — as a menu, or as a row of choices with the active one held |
| **Colour** | `[h, s, v]`, `[r, g, b]` or `"#rrggbb"` | a hue wheel with saturation and brightness, for an RGB fixture | | **Colour** | `[h, s, v]`, `[r, g, b]` or `"#rrggbb"` | a hue wheel with saturation and brightness, for an RGB fixture |
@@ -121,6 +122,27 @@ messages to carry the occasional still that a flow can actually react to.
Panels see media the same way, and only their own: a screen may fetch the bytes Panels see media the same way, and only their own: a screen may fetch the bytes
its own tiles are showing and nothing else. its own tiles are showing and nothing else.
## Player tiles
A player is the one tile that both reads and publishes, so it has two bindings.
It **shows** a `record` describing what is playing and **publishes to** a `str`
carrying what to do about it:
| Field of the record | Means |
|---|---|
| `title`, `artist`, `album` | what is playing |
| `status` | `play`, `pause`, `stop`, `load`, or `off` for a streamer with no power |
| `position`, `duration` | seconds, both |
The buttons and the bar publish words: `toggle`, `next`, `prev` and
`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 node that
receives them decides what they mean for its device.
The position counts forward in the browser between readings, so the bar moves
at one second while the device is polled at whatever rate suits it. Every
reading that arrives is taken as the truth and the count restarts from it.
## Dashboard settings ## Dashboard settings
Most of what a dashboard carries is a widget: a tile bound to a message. A Most of what a dashboard carries is a widget: a tile bound to a message. A
+1 -1
View File
@@ -3762,7 +3762,7 @@ export const WidgetDefSchema = {
}, },
type: { type: {
type: 'string', type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'button', 'switch', 'slider', 'input', 'dropdown', 'color'], enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'button', 'switch', 'slider', 'input', 'dropdown', 'color'],
title: 'Type' title: 'Type'
}, },
title: { title: {
+2 -2
View File
@@ -1297,7 +1297,7 @@ export type WebPushKey = {
*/ */
export type WidgetDef = { export type WidgetDef = {
id: string; id: string;
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color'; type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
title?: string; title?: string;
layout?: { layout?: {
[key: string]: Placement; [key: string]: Placement;
@@ -1307,7 +1307,7 @@ export type WidgetDef = {
}; };
}; };
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color'; export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
export type WorkerInfo = { export type WorkerInfo = {
name: string; name: string;
@@ -0,0 +1,149 @@
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>
)
}
@@ -19,6 +19,7 @@ import {
Lightbulb, Lightbulb,
type LucideIcon, type LucideIcon,
Moon, Moon,
Music,
Plug, Plug,
Snowflake, Snowflake,
Sun, Sun,
@@ -27,6 +28,8 @@ import {
ThermometerSun, ThermometerSun,
TriangleAlert, TriangleAlert,
Umbrella, Umbrella,
Volume2,
VolumeX,
Wind, Wind,
Zap, Zap,
} from "lucide-react" } from "lucide-react"
@@ -64,6 +67,9 @@ export const ICONS: Record<string, LucideIcon> = {
bed: Bed, bed: Bed,
lightbulb: Lightbulb, lightbulb: Lightbulb,
plug: Plug, plug: Plug,
music: Music,
"volume-2": Volume2,
"volume-x": VolumeX,
zap: Zap, zap: Zap,
"battery-charging": BatteryCharging, "battery-charging": BatteryCharging,
"arrow-up": ArrowUp, "arrow-up": ArrowUp,
@@ -1011,6 +1011,26 @@ export function WidgetPanel({
</div> </div>
) : null} ) : null}
{widget.type === "slider" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Drawn as</Label>
<Segmented
value={str(cfg.orientation) || "horizontal"}
options={[
["horizontal", "Row"],
["vertical", "Fader"],
]}
label="How this slider is drawn"
testId="widget-orientation"
onChange={(orientation) => set({ orientation })}
/>
<p className="text-xs text-muted-foreground">
A fader takes the tile's height, so give it a tall one. Its scale
is dropped either way: a column has no room for the labels.
</p>
</div>
) : null}
{widget.type === "button" ? ( {widget.type === "button" ? (
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label> <Label className="text-sm font-normal">Sends</Label>
@@ -1278,6 +1298,31 @@ export function WidgetPanel({
</div> </div>
) : null} ) : null}
{widget.type === "player" ? (
<div className="grid gap-1.5">
<MessagePicker
kind="player"
value={str(cfg.target)}
label="Publishes to"
testId="widget-target"
// A player is the one widget with two bindings, so its target
// cannot come off the type: it sends words — `toggle`, `next`,
// `seek:90` — while the reading it draws is a record.
filter={(message) =>
message.dtype === "str" && message.writable !== false
}
onPick={(target) => set({ target })}
/>
<p className="text-xs text-muted-foreground">
Transport commands go here as words:{" "}
<span className="font-mono">toggle</span>,{" "}
<span className="font-mono">next</span>,{" "}
<span className="font-mono">prev</span> and{" "}
<span className="font-mono">seek:&lt;seconds&gt;</span>.
</p>
</div>
) : null}
{widget.type === "switch" || widget.type === "dropdown" ? ( {widget.type === "switch" || widget.type === "dropdown" ? (
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label className="text-sm font-normal">Style</Label> <Label className="text-sm font-normal">Style</Label>
+45 -14
View File
@@ -15,6 +15,7 @@ import "./dashboard.css"
import { ForecastWidget } from "./ForecastWidget" import { ForecastWidget } from "./ForecastWidget"
import { IconWidget } from "./IconWidget" import { IconWidget } from "./IconWidget"
import { MediaWidget } from "./MediaWidget" import { MediaWidget } from "./MediaWidget"
import { PlayerWidget } from "./PlayerWidget"
import { usePublish } from "./publish" import { usePublish } from "./publish"
import { useUi } from "./ui" import { useUi } from "./ui"
import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color" import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color"
@@ -58,6 +59,10 @@ export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
// bound to; a plain artifact is taken as well, since the media type on the // bound to; a plain artifact is taken as well, since the media type on the
// reference is what says what the bytes are. // reference is what says what the bytes are.
media: ["image", "audio", "video", "artifact"], media: ["image", "audio", "video", "artifact"],
// What a streamer says it is playing: title, artist, status, position and
// duration in one reading, because they are one thing and a player drawn
// from five separate messages would redraw itself five times.
player: ["record"],
// An icon maps weather strings, bool hints and numbers alike, and a clock // An icon maps weather strings, bool hints and numbers alike, and a clock
// binds nothing at all, so neither has a row to be held to. // binds nothing at all, so neither has a row to be held to.
} }
@@ -80,6 +85,7 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
forecast: "Forecast", forecast: "Forecast",
clock: "Clock", clock: "Clock",
media: "Media", media: "Media",
player: "Player",
button: "Button", button: "Button",
switch: "Switch", switch: "Switch",
slider: "Slider", slider: "Slider",
@@ -101,6 +107,7 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
forecast: { w: 6, h: 2 }, forecast: { w: 6, h: 2 },
clock: { w: 3, h: 2 }, clock: { w: 3, h: 2 },
media: { w: 4, h: 4 }, media: { w: 4, h: 4 },
player: { w: 4, h: 3 },
button: { w: 3, h: 2 }, button: { w: 3, h: 2 },
switch: { w: 3, h: 2 }, switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 }, slider: { w: 4, h: 2 },
@@ -191,6 +198,12 @@ export function widgetIssue(widget: WidgetDef): string | null {
return null return null
} }
// A player is the one widget that both reads and publishes, so it is the one
// whose wiring is only half done when a single picker is filled in.
if (widget.type === "player" && text(cfg.message) && !text(cfg.target)) {
return "This player does not publish to a message yet."
}
const input = INPUT_WIDGETS.has(widget.type) const input = INPUT_WIDGETS.has(widget.type)
const bound = text(cfg[input ? "target" : "message"]) const bound = text(cfg[input ? "target" : "message"])
if (!bound) { if (!bound) {
@@ -494,37 +507,54 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
if (!target) return <Unbound /> if (!target) return <Unbound />
const unit = cfg.unit ? text(cfg.unit) : undefined const unit = cfg.unit ? text(cfg.unit) : undefined
return ( const vertical = cfg.orientation === "vertical"
// Value beside the track rather than above it, the way a bar row reads — const reading = typeof value === "number" ? value : min
// one row instead of two, and the tile keeps the height for the control. const control = (
<div className="flex items-start gap-3">
{pulse}
<div className="min-w-0 flex-1">
<Slider <Slider
value={typeof value === "number" ? value : min} value={reading}
min={min} min={min}
max={num(cfg.max, 100)} max={num(cfg.max, 100)}
step={num(cfg.step, 1)} step={num(cfg.step, 1)}
unit={unit} unit={unit}
orientation={vertical ? "vertical" : "horizontal"}
// On by default, because a slider without them says how far along it // On by default, because a slider without them says how far along it
// is and not what that means. Off is for a short tile: they are the // is and not what that means. Off is for a short tile: they are the
// last row of a control that has three, and on a seven-inch panel // last row of a control that has three, and on a seven-inch panel
// that row is the difference between fitting and being cut off. // that row is the difference between fitting and being cut off. A
// column has no room for them at all.
ticks={cfg.ticks !== false} ticks={cfg.ticks !== false}
label={widget.title || target} label={widget.title || target}
disabled={locked} disabled={locked}
onCommit={send} onCommit={send}
/> />
)
const readout = (
<Readout value={reading} precision={null} unit={unit} size="inline" />
)
// A column takes the tile's height and puts the reading under it, which is
// what a fader looks like. A volume control is the case it exists for: it is
// reached for without looking, and up is louder.
if (vertical) {
return (
<div className="flex h-full min-h-0 flex-col items-center gap-2">
{pulse}
{control}
<div className="shrink-0">{readout}</div>
</div> </div>
)
}
return (
// Value beside the track rather than above it, the way a bar row reads —
// one row instead of two, and the tile keeps the height for the control.
<div className="flex items-start gap-3">
{pulse}
<div className="min-w-0 flex-1">{control}</div>
{/* Held to the control's own height, so the value sits on the track's {/* Held to the control's own height, so the value sits on the track's
midline whether or not there is a row of ticks under it. */} midline whether or not there is a row of ticks under it. */}
<div className="flex h-[var(--dui-control)] shrink-0 items-center"> <div className="flex h-[var(--dui-control)] shrink-0 items-center">
<Readout {readout}
value={typeof value === "number" ? value : min}
precision={null}
unit={unit}
size="inline"
/>
</div> </div>
</div> </div>
) )
@@ -622,6 +652,7 @@ const RENDERERS: Partial<
forecast: ForecastWidget, forecast: ForecastWidget,
clock: ClockWidget, clock: ClockWidget,
media: MediaWidget, media: MediaWidget,
player: PlayerWidget,
button: ButtonWidget, button: ButtonWidget,
switch: SwitchWidget, switch: SwitchWidget,
slider: SliderWidget, slider: SliderWidget,