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:
@@ -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,
|
||||
type LucideIcon,
|
||||
Moon,
|
||||
Music,
|
||||
Plug,
|
||||
Snowflake,
|
||||
Sun,
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
ThermometerSun,
|
||||
TriangleAlert,
|
||||
Umbrella,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Wind,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
@@ -64,6 +67,9 @@ export const ICONS: Record<string, LucideIcon> = {
|
||||
bed: Bed,
|
||||
lightbulb: Lightbulb,
|
||||
plug: Plug,
|
||||
music: Music,
|
||||
"volume-2": Volume2,
|
||||
"volume-x": VolumeX,
|
||||
zap: Zap,
|
||||
"battery-charging": BatteryCharging,
|
||||
"arrow-up": ArrowUp,
|
||||
|
||||
@@ -1011,6 +1011,26 @@ export function WidgetPanel({
|
||||
</div>
|
||||
) : 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" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Sends</Label>
|
||||
@@ -1278,6 +1298,31 @@ export function WidgetPanel({
|
||||
</div>
|
||||
) : 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:<seconds></span>.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "switch" || widget.type === "dropdown" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Style</Label>
|
||||
|
||||
@@ -15,6 +15,7 @@ import "./dashboard.css"
|
||||
import { ForecastWidget } from "./ForecastWidget"
|
||||
import { IconWidget } from "./IconWidget"
|
||||
import { MediaWidget } from "./MediaWidget"
|
||||
import { PlayerWidget } from "./PlayerWidget"
|
||||
import { usePublish } from "./publish"
|
||||
import { useUi } from "./ui"
|
||||
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
|
||||
// reference is what says what the bytes are.
|
||||
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
|
||||
// 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",
|
||||
clock: "Clock",
|
||||
media: "Media",
|
||||
player: "Player",
|
||||
button: "Button",
|
||||
switch: "Switch",
|
||||
slider: "Slider",
|
||||
@@ -101,6 +107,7 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
|
||||
forecast: { w: 6, h: 2 },
|
||||
clock: { w: 3, h: 2 },
|
||||
media: { w: 4, h: 4 },
|
||||
player: { w: 4, h: 3 },
|
||||
button: { w: 3, h: 2 },
|
||||
switch: { w: 3, h: 2 },
|
||||
slider: { w: 4, h: 2 },
|
||||
@@ -191,6 +198,12 @@ export function widgetIssue(widget: WidgetDef): string | 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 bound = text(cfg[input ? "target" : "message"])
|
||||
if (!bound) {
|
||||
@@ -494,37 +507,54 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
|
||||
if (!target) return <Unbound />
|
||||
|
||||
const unit = cfg.unit ? text(cfg.unit) : undefined
|
||||
const vertical = cfg.orientation === "vertical"
|
||||
const reading = typeof value === "number" ? value : min
|
||||
const control = (
|
||||
<Slider
|
||||
value={reading}
|
||||
min={min}
|
||||
max={num(cfg.max, 100)}
|
||||
step={num(cfg.step, 1)}
|
||||
unit={unit}
|
||||
orientation={vertical ? "vertical" : "horizontal"}
|
||||
// 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
|
||||
// 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. A
|
||||
// column has no room for them at all.
|
||||
ticks={cfg.ticks !== false}
|
||||
label={widget.title || target}
|
||||
disabled={locked}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<Slider
|
||||
value={typeof value === "number" ? value : min}
|
||||
min={min}
|
||||
max={num(cfg.max, 100)}
|
||||
step={num(cfg.step, 1)}
|
||||
unit={unit}
|
||||
// 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
|
||||
// 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.
|
||||
ticks={cfg.ticks !== false}
|
||||
label={widget.title || target}
|
||||
disabled={locked}
|
||||
onCommit={send}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">{control}</div>
|
||||
{/* 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. */}
|
||||
<div className="flex h-[var(--dui-control)] shrink-0 items-center">
|
||||
<Readout
|
||||
value={typeof value === "number" ? value : min}
|
||||
precision={null}
|
||||
unit={unit}
|
||||
size="inline"
|
||||
/>
|
||||
{readout}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -622,6 +652,7 @@ const RENDERERS: Partial<
|
||||
forecast: ForecastWidget,
|
||||
clock: ClockWidget,
|
||||
media: MediaWidget,
|
||||
player: PlayerWidget,
|
||||
button: ButtonWidget,
|
||||
switch: SwitchWidget,
|
||||
slider: SliderWidget,
|
||||
|
||||
Reference in New Issue
Block a user