diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index 40db80c..3bafb06 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -64,6 +64,7 @@ WidgetType = Literal[ "forecast", "clock", "media", + "player", # Input "button", "switch", @@ -73,6 +74,9 @@ WidgetType = Literal[ "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"} #: 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 # anything and the media type on the reference is what says what they 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. } diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 67ce519..de75df9 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -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(): WidgetDef( id="b", diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index cb36509..ee74dca 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -35,6 +35,7 @@ and dragging is off. Picking a widget and editing its settings still works. | **Forecast** | `list` | a short outlook strip | | **Notification** | `record` | title, body and severity — what an alert channel writes | | **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 | 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 | | **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 | | **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 | @@ -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 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:`. 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 Most of what a dashboard carries is a widget: a tile bound to a message. A diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 1dba9c3..9505d17 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -3762,7 +3762,7 @@ export const WidgetDefSchema = { }, type: { 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: { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 9ec8f49..05a85cd 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1297,7 +1297,7 @@ export type WebPushKey = { */ export type WidgetDef = { 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; layout?: { [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 = { name: string; diff --git a/frontend/src/components/Dashboard/PlayerWidget.tsx b/frontend/src/components/Dashboard/PlayerWidget.tsx new file mode 100644 index 0000000..de2a346 --- /dev/null +++ b/frontend/src/components/Dashboard/PlayerWidget.tsx @@ -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:`), 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)} +
+ +
+ + + +
+
+ ) +} diff --git a/frontend/src/components/Dashboard/icons.ts b/frontend/src/components/Dashboard/icons.ts index ad27279..3bc7edb 100644 --- a/frontend/src/components/Dashboard/icons.ts +++ b/frontend/src/components/Dashboard/icons.ts @@ -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 = { bed: Bed, lightbulb: Lightbulb, plug: Plug, + music: Music, + "volume-2": Volume2, + "volume-x": VolumeX, zap: Zap, "battery-charging": BatteryCharging, "arrow-up": ArrowUp, diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 367fdab..9cf81ee 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -1011,6 +1011,26 @@ export function WidgetPanel({ ) : null} + {widget.type === "slider" ? ( +
+ + set({ orientation })} + /> +

+ 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. +

+
+ ) : null} + {widget.type === "button" ? (
@@ -1278,6 +1298,31 @@ export function WidgetPanel({
) : null} + {widget.type === "player" ? ( +
+ + message.dtype === "str" && message.writable !== false + } + onPick={(target) => set({ target })} + /> +

+ Transport commands go here as words:{" "} + toggle,{" "} + next,{" "} + prev and{" "} + seek:<seconds>. +

+
+ ) : null} + {widget.type === "switch" || widget.type === "dropdown" ? (
diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index a6fa019..6890432 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -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> = { // 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 = { forecast: "Forecast", clock: "Clock", media: "Media", + player: "Player", button: "Button", switch: "Switch", slider: "Slider", @@ -101,6 +107,7 @@ export const WIDGET_SIZES: Record = { 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 const unit = cfg.unit ? text(cfg.unit) : undefined + const vertical = cfg.orientation === "vertical" + const reading = typeof value === "number" ? value : min + const control = ( + + ) + const readout = ( + + ) + + // 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 ( +
+ {pulse} + {control} +
{readout}
+
+ ) + } + 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.
{pulse} -
- -
+
{control}
{/* 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. */}
- + {readout}
) @@ -622,6 +652,7 @@ const RENDERERS: Partial< forecast: ForecastWidget, clock: ClockWidget, media: MediaWidget, + player: PlayerWidget, button: ButtonWidget, switch: SwitchWidget, slider: SliderWidget,