@hashsome/ui 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -96,6 +96,17 @@ export function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size
96
96
  }
97
97
  }
98
98
  },
99
+ // A touch the browser took or lost never ends with a release: let go of the position.
100
+ onPointerCancel: () => {
101
+ drag.current = null;
102
+ seek.cancel();
103
+ },
104
+ onLostPointerCapture: () => {
105
+ if (drag.current) {
106
+ drag.current = null;
107
+ seek.cancel();
108
+ }
109
+ },
99
110
  }
100
111
  : {}), css: { inset: 0, touchAction: 'none' }, children: _jsxs("svg", { viewBox: "0 0 100 100", width: SIZE, height: SIZE, "aria-hidden": "true", children: [_jsx("circle", { cx: "50", cy: "50", r: "47", fill: "none", strokeWidth: "3", css: ({ palette }) => ({ stroke: palette.border }) }), fraction !== undefined ? (_jsx("circle", { cx: "50", cy: "50", r: "47", fill: "none", strokeWidth: "3", strokeLinecap: "round", pathLength: 100, strokeDasharray: `${fraction * 100} 100`, transform: "rotate(-90 50 50)", css: ({ palette }) => ({ stroke: palette.accent }) })) : null] }) }), _jsx(Box, { position: "absolute", radius: "full", border: true, css: { inset: '9%' } }), _jsx(Flex, { center: true, position: "absolute", radius: "full", overflow: "hidden", background: "surfaceRaised", color: "textMuted", cursor: onOpen ? 'pointer' : 'inherit', css: { inset: '14%' }, ...(onOpen
101
112
  ? {
@@ -19,6 +19,8 @@ export interface LightTileProps {
19
19
  history?: LogbookEntry[];
20
20
  /** Swatches shown when the palette button is opened. Default: four white temperatures. Each takes `kelvin` (color-temp lights) and/or `hs` (color lights) and is skipped if the light can't apply it. */
21
21
  colors?: LightColorPreset[];
22
+ /** Holding the tile opens a drawer with the full controls, the color bars, the energy chart and the recent activity. `false` leaves the tile as it is without it: a toggle that dims by dragging, with the color swatches if the light has color, and no drawer built, which is lighter on a small display. Default `true`. */
23
+ drawer?: boolean;
22
24
  }
23
25
  /** Toggle, drag-to-dim (if dimmable) and a color overlay + drawer picker (if color capable). */
24
- export declare function LightTile({ entity, name, icon, energy, history, colors, }: LightTileProps): import("@emotion/react/jsx-runtime").JSX.Element;
26
+ export declare function LightTile({ entity, name, icon, energy, history, colors, drawer, }: LightTileProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -14,7 +14,7 @@ const kelvinGradient = (min, max) => `linear-gradient(to right, ${[0, 0.25, 0.5,
14
14
  .map((f) => rgbCss(kelvinToRgb(min + f * (max - min))))
15
15
  .join(', ')})`;
16
16
  /** Toggle, drag-to-dim (if dimmable) and a color overlay + drawer picker (if color capable). */
17
- export function LightTile({ entity, name, icon, energy, history, colors = DEFAULT_COLOR_PRESETS, }) {
17
+ export function LightTile({ entity, name, icon, energy, history, colors = DEFAULT_COLOR_PRESETS, drawer = true, }) {
18
18
  const handle = useEntityHandle('light', entity);
19
19
  const [picking, setPicking] = useState(false);
20
20
  const [colorOpen, setColorOpen] = useState(false);
@@ -36,20 +36,24 @@ export function LightTile({ entity, name, icon, energy, history, colors = DEFAUL
36
36
  return data ? [{ preset, data }] : [];
37
37
  });
38
38
  const showPicker = color && status === 'ready';
39
- return (_jsx(Tile, { label: name ?? light?.name ?? fallbackName(entity), icon: icon ?? (on ? 'lu:lightbulb' : 'lu:lightbulb-off'), kind: "Light", status: status, active: on, secondary: on ? (dimmable && percent !== undefined ? `${percent}%` : undefined) : 'Off', onPress: () => void handle.command('toggle'), detail: _jsx(LightDetailBody, { on: on, percent: dimmable ? percent : undefined, onToggle: () => void handle.command('toggle'), ...(dimmable
40
- ? {
41
- onBrightness: (next) => void handle.command('setBrightness', { brightness: next / 100 }),
42
- }
43
- : {}), colorSection: showPicker ? (_jsx(LightColorSection, { light: light, open: colorOpen, onToggle: () => setColorOpen((open) => !open), supportsTemp: supportsTemp, supportsHs: supportsHs, onKelvin: (kelvin) => void handle.command('setColorTemperature', { kelvin }), onHue: (hue) => void handle.command('setColor', {
44
- hue,
45
- saturation: currentHs(light)[1] || 100,
46
- }), onPicked: (hex) => {
47
- const picked = hexToRgb(hex);
48
- if (picked) {
49
- const [hue, saturation] = rgbToHs(picked);
50
- void handle.command('setColor', { hue, saturation });
51
- }
52
- } })) : null }), ...(energy ? { energy } : {}), ...(history ? { history } : {}), ...(typeof entity === 'string' ? { logbook: entity } : {}), ...(dimmable
39
+ return (_jsx(Tile, { label: name ?? light?.name ?? fallbackName(entity), icon: icon ?? (on ? 'lu:lightbulb' : 'lu:lightbulb-off'), kind: "Light", status: status, active: on, secondary: on ? (dimmable && percent !== undefined ? `${percent}%` : undefined) : 'Off', onPress: () => void handle.command('toggle'), ...(drawer
40
+ ? {
41
+ detail: (_jsx(LightDetailBody, { on: on, percent: dimmable ? percent : undefined, onToggle: () => void handle.command('toggle'), ...(dimmable
42
+ ? {
43
+ onBrightness: (next) => void handle.command('setBrightness', { brightness: next / 100 }),
44
+ }
45
+ : {}), colorSection: showPicker ? (_jsx(LightColorSection, { light: light, open: colorOpen, onToggle: () => setColorOpen((open) => !open), supportsTemp: supportsTemp, supportsHs: supportsHs, onKelvin: (kelvin) => void handle.command('setColorTemperature', { kelvin }), onHue: (hue) => void handle.command('setColor', {
46
+ hue,
47
+ saturation: currentHs(light)[1] || 100,
48
+ }), onPicked: (hex) => {
49
+ const picked = hexToRgb(hex);
50
+ if (picked) {
51
+ const [hue, saturation] = rgbToHs(picked);
52
+ void handle.command('setColor', { hue, saturation });
53
+ }
54
+ } })) : null })),
55
+ }
56
+ : {}), ...(drawer && energy ? { energy } : {}), ...(drawer && history ? { history } : {}), ...(drawer && typeof entity === 'string' ? { logbook: entity } : {}), ...(dimmable
53
57
  ? {
54
58
  fill: on ? (brightness ?? 1) : 0,
55
59
  onFillChange: (fill) => void handle.command('setBrightness', { brightness: fill }),
@@ -69,11 +73,16 @@ export function LightTile({ entity, name, icon, energy, history, colors = DEFAUL
69
73
  : handle.command('setColor', data.args));
70
74
  setPicking(false);
71
75
  },
72
- })), customLabel: "Custom color", onCustom: () => {
73
- setPicking(false);
74
- setColorOpen(true);
75
- openDetail();
76
- } })),
76
+ })), ...(drawer
77
+ ? {
78
+ customLabel: 'Custom color',
79
+ onCustom: () => {
80
+ setPicking(false);
81
+ setColorOpen(true);
82
+ openDetail();
83
+ },
84
+ }
85
+ : {}) })),
77
86
  }
78
87
  : {}), trailing: showPicker ? (_jsx(IconButton, { icon: picking ? 'lu:x' : 'lu:palette', label: picking ? 'Close colors' : 'Color', active: false, onClick: () => setPicking((open) => !open), size: 16, ...(on && paletteColor && !picking ? { color: paletteColor } : {}) })) : null }));
79
88
  }
@@ -9,6 +9,14 @@ export interface MediaPlayerBarProps {
9
9
  name?: string;
10
10
  /** Content for the media browser shown with the player in the drawer. Holding the card or pressing the artwork opens the drawer; the browse button opens it expanded. By default a `MediaBrowser` over the player's own library, shown only for a ref whose player has one; pass your own content to replace it, or `false` for no browse button. */
11
11
  browse?: ReactNode | false;
12
+ /** `false` builds no drawer: holding the card, pressing the artwork and the browse button then do nothing, which is lighter on a small display. Default `true`. See `onOpen` to send them somewhere else instead. */
13
+ drawer?: boolean;
14
+ /** Called instead of opening the drawer when the card is held, the artwork pressed or the browse button pressed: for a dashboard with a page of its own for the player (a `MediaPlayerFull`), `onOpen={() => navigate('/bathroom/music')}`. Given, no drawer is built. The browse button stays unless `browse` is `false`. */
15
+ onOpen?: () => void;
16
+ /** The library and the queue each open as a full-size overlay of their own, from the browse button and a new queue button, instead of one drawer with the whole player in it: for a small display, where the card itself is the player. The library overlay is a `MediaBrowser` as a list (or your `browse` content), the queue overlay a `MediaQueue`; each button is left out when the player has none, or for `browse={false}`. Holding the card and pressing the artwork then do nothing, unless `onOpen` is given. Default `false`. */
17
+ overlays?: boolean;
18
+ /** `1` puts the controls beside the track, in one pill; `2` puts them on a second row under it, for a narrow space (a small wall display). Default `'auto'`: one row, and two once the bar is narrower than 560 px wide. */
19
+ rows?: 1 | 2 | 'auto';
12
20
  }
13
21
  /** Now playing with previous / play-pause / next and a volume control, for a media player entity. */
14
- export declare function MediaPlayerBar({ entity, name, browse }: MediaPlayerBarProps): import("@emotion/react/jsx-runtime").JSX.Element;
22
+ export declare function MediaPlayerBar({ entity, name, browse, drawer, onOpen, overlays, rows, }: MediaPlayerBarProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -20,12 +20,15 @@ import { MediaQueue } from './media-queue.js';
20
20
  import { formatDuration, useMediaPosition } from './media-progress.js';
21
21
  import { useSeekHold } from './media-seek.js';
22
22
  import { SeekLine } from './seek-line.js';
23
+ /** Narrower than this, a bar with `rows="auto"` is two rows. */
24
+ const TWO_ROWS_BELOW = 560;
23
25
  /** Glyph size inside the player's 44px circles — smaller than an icon's default so there is breathing room around it. */
24
26
  const MEDIA_GLYPH = 18;
25
27
  /** The ring around the artwork (the size of an icon circle): a button when there is somewhere to
26
- * go, otherwise just the ring. */
27
- function Ring({ onOpen, children }) {
28
- return (_jsx(Flex, { ...(onOpen
28
+ * go, otherwise just the ring. `fraction` (0 to 1) fills it with how far along playback is, from the
29
+ * top, like the large player's ring. */
30
+ function Ring({ onOpen, fraction, children, }) {
31
+ return (_jsxs(Flex, { ...(onOpen
29
32
  ? {
30
33
  as: 'button',
31
34
  type: 'button',
@@ -34,23 +37,102 @@ function Ring({ onOpen, children }) {
34
37
  onClick: onOpen,
35
38
  cursor: 'pointer',
36
39
  }
37
- : { as: 'span' }), center: true, position: "relative", p: 0, border: true, radius: "full", css: ({ density }) => ({
40
+ : { as: 'span' }), "data-part": "ring", center: true, position: "relative", p: 0, radius: "full", css: ({ density }) => ({
38
41
  width: density.iconCircle,
39
42
  height: density.iconCircle,
40
43
  flex: 'none',
41
44
  background: 'none',
42
- }), children: children }));
45
+ }), children: [_jsxs("svg", { viewBox: "0 0 100 100", "aria-hidden": "true", css: {
46
+ position: 'absolute',
47
+ inset: 0,
48
+ width: '100%',
49
+ height: '100%',
50
+ pointerEvents: 'none',
51
+ }, children: [_jsx("circle", { cx: "50", cy: "50", r: "47.5", fill: "none", strokeWidth: "4", css: ({ palette }) => ({ stroke: palette.border }) }), fraction !== undefined ? (_jsx("circle", { "data-part": "progress", cx: "50", cy: "50", r: "47.5", fill: "none", strokeWidth: "4", strokeLinecap: "round", pathLength: 100, strokeDasharray: `${fraction * 100} 100`, transform: "rotate(-90 50 50)", css: ({ palette }) => ({ stroke: palette.accent }) })) : null] }), children] }));
43
52
  }
44
53
  /** The same spring the swatch rows pop in with, so the volume overlay opens like they do. */
45
54
  const VOLUME_SPRING = { type: 'spring', stiffness: 520, damping: 26 };
55
+ /** What the pill is like in two rows: the artwork, "title · artist" and the time on top; under them
56
+ * the buttons share the whole width, all of one size, and while the volume slider is open it has the
57
+ * row to itself, with only the button that closes it. The bar is as tall as two regular tiles and
58
+ * the gap between them (a tile is its icon circle and a spacing unit), so it sits in a grid of
59
+ * tiles. A line break between the rows comes from an empty item that fills a line of its own. */
60
+ const stacked = ({ radius, spacing, density, }) => ({
61
+ display: 'flex',
62
+ flexWrap: 'wrap',
63
+ alignItems: 'center',
64
+ alignContent: 'center',
65
+ columnGap: spacing(2.5),
66
+ rowGap: 0,
67
+ minHeight: 2 * (density.iconCircle + density.space / 3) + density.space,
68
+ paddingTop: spacing(2.5),
69
+ paddingBottom: spacing(2.5),
70
+ borderRadius: radius.card,
71
+ '&::before': { content: '""', order: 1, flexBasis: '100%', height: 0 },
72
+ '& > [data-part="track"]': {
73
+ flex: '1 1 0',
74
+ minWidth: 0,
75
+ flexDirection: 'row',
76
+ alignItems: 'baseline',
77
+ gap: spacing(1.5),
78
+ // The title and the artist share the line and shorten, with an ellipsis, before they run into
79
+ // the time; the artist follows the title after a dot.
80
+ '& > :nth-child(1)': {
81
+ flex: '0 1 auto',
82
+ minWidth: 0,
83
+ // The title is what gives way, not the shuffle button beside it.
84
+ '& > :first-child': { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' },
85
+ },
86
+ '& > :nth-child(2)': {
87
+ flex: '0 1 auto',
88
+ minWidth: 0,
89
+ overflow: 'hidden',
90
+ '&::before': { content: '"·"', marginRight: spacing(1.5) },
91
+ },
92
+ },
93
+ '& [data-part="tools"], & [data-part="transport"]': { display: 'contents' },
94
+ '& [data-part="tools"] > button, & [data-part="transport"] > button': {
95
+ order: 2,
96
+ flex: '1 1 0',
97
+ minWidth: 0,
98
+ width: 'auto',
99
+ height: 34,
100
+ marginTop: spacing(2.5),
101
+ },
102
+ '& [data-part="transport"] > button': { order: 4 },
103
+ '& > [data-part="volume"]': {
104
+ order: 3,
105
+ flex: '1 1 0',
106
+ minWidth: 0,
107
+ marginTop: spacing(2.5),
108
+ },
109
+ '&[data-volume-open="true"]': {
110
+ '& > [data-part="track"], & > [data-part="time"]': { display: 'flex' },
111
+ // The slider has the row: only the button that closes it stays, a circle beside it.
112
+ '& [data-part="tools"] > button:nth-of-type(n + 2), & [data-part="transport"] > button': {
113
+ display: 'none',
114
+ },
115
+ '& [data-part="tools"] > button:first-of-type': { flex: 'none', width: 34 },
116
+ },
117
+ });
46
118
  /** The bar itself. Holding it (anywhere but on a button or slider) opens the drawer, with a thin
47
119
  * line along the top that fills while the press is timed, like a tile's. */
48
- function HoldCard({ open, status, children, }) {
120
+ function HoldCard({ open, status, rows, volumeOpen, children, }) {
49
121
  const hold = useHold(() => open?.(), open !== undefined);
50
- return (_jsxs(Flex, { position: "relative", align: "center", gap: 3, pl: 2.25, pr: 3, background: "surface", radius: "full", overflow: "hidden", "data-status": status, css: ({ density }) => ({ minHeight: density.tileHeight }), ...hold.handlers, children: [children, hold.holding ? _jsx(HoldProgress, {}) : null] }));
122
+ const card = (_jsxs(Flex, { position: "relative", align: "center", grow: 1, minWidth: 0, gap: 3, pl: 2.25, pr: 3, background: "surface", radius: "full", overflow: "hidden", "data-status": status, "data-rows": rows === 2 ? 2 : undefined, "data-volume-open": volumeOpen, css: (theme) => ({
123
+ minHeight: theme.density.tileHeight,
124
+ // In one row the volume slider takes the track's place; in two it has its own row.
125
+ '&[data-volume-open="true"] > [data-part="track"], &[data-volume-open="true"] > [data-part="time"]': { display: 'none' },
126
+ ...(rows === 2 ? stacked(theme) : {}),
127
+ ...(rows === 'auto'
128
+ ? { [`@container (max-width: ${TWO_ROWS_BELOW - 1}px)`]: stacked(theme) }
129
+ : {}),
130
+ }), ...hold.handlers, children: [children, hold.holding ? _jsx(HoldProgress, {}) : null] }));
131
+ // `auto` asks the width the bar is given, so its parent is the thing measured.
132
+ return rows === 'auto' ? (_jsx(Flex, { width: "100%", css: { containerType: 'inline-size' }, children: card })) : (card);
51
133
  }
52
134
  /** Now playing with previous / play-pause / next and a volume control, for a media player entity. */
53
- export function MediaPlayerBar({ entity, name, browse }) {
135
+ export function MediaPlayerBar({ entity, name, browse, drawer = true, onOpen, overlays = false, rows = 'auto', }) {
54
136
  const handle = useEntityHandle('mediaPlayer', entity);
55
137
  const player = handle.entity;
56
138
  const status = handle.status;
@@ -66,14 +148,42 @@ export function MediaPlayerBar({ entity, name, browse }) {
66
148
  const seek = useSeekHold(ready ? position : undefined, caps?.seek ? (next) => void handle.command('seek', { position: next }) : undefined);
67
149
  const duration = ready ? player?.duration : undefined;
68
150
  const timed = seek.shown !== undefined && duration !== undefined && duration > 0;
151
+ // How far along playback is, for the ring round the artwork.
152
+ const progress = timed && duration !== undefined
153
+ ? Math.min(1, Math.max(0, (seek.shown ?? 0) / duration))
154
+ : undefined;
69
155
  const ref = typeof entity === 'string' ? entity : undefined;
70
156
  const browser = browse === false
71
157
  ? undefined
72
158
  : (browse ?? (ref && caps?.browse ? _jsx(MediaBrowser, { entity: ref, layout: "auto" }) : undefined));
73
- const bar = (open, openExpanded) => (_jsxs(HoldCard, { open: open, status: status, children: [_jsx(Ring, { onOpen: open, children: artwork }), volumeOpen && ready ? (_jsxs(Flex, { as: motion.div, align: "center", gap: 2, grow: 1, minWidth: 0, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.15 }, children: [_jsx(PlainButton, { as: motion.button, initial: { scale: 0, opacity: 0 }, animate: { scale: 1, opacity: 1 }, transition: VOLUME_SPRING, "aria-label": muted ? 'Unmute' : 'Mute', "aria-pressed": muted, title: muted ? 'Unmute' : 'Mute', onClick: volume.toggleMute, center: true, color: muted ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: muted ? 'lu:volume-x' : 'lu:volume-2', size: 16 }) }), _jsx(Flex, { as: motion.div, grow: 1, minWidth: 0, initial: { scaleX: 0.4, opacity: 0 }, animate: { scaleX: 1, opacity: 1 }, transition: { ...VOLUME_SPRING, delay: 0.04 }, css: { transformOrigin: 'left center' }, children: _jsx(ValueBar, { label: "Volume level", value: volume.shown, min: 0, max: 100, keyStep: 5, height: 10, onDrag: volume.drag, onCommit: volume.commit }) }), _jsxs(Typography, { as: motion.span, variant: "secondary", color: "textMuted", minWidth: 32, align: "right", css: { flex: 'none' }, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2, delay: 0.1 }, children: [volume.shown, "%"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Flex, { direction: "column", grow: 1, minWidth: 0, ...(ready ? {} : { color: 'textMuted' }), children: [_jsxs(Flex, { align: "center", gap: 1.5, minWidth: 0, children: [_jsx(Typography, { as: "span", variant: "label", noWrap: true, textOverflow: "ellipsis", children: ready
74
- ? (player?.media?.title ?? 'Nothing playing')
75
- : statusLabels[status] }), ready && caps?.shuffle ? (_jsx(PlainButton, { "aria-label": "Shuffle", "aria-pressed": player?.shuffle === true, title: "Shuffle", onClick: () => void handle.command('setShuffle', { shuffle: player?.shuffle !== true }), center: true, color: player?.shuffle === true ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:shuffle", size: 14 }) })) : null] }), ready && player?.media?.artist ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", children: player.media.artist })) : null] }), timed ? (_jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, css: { flex: 'none', fontVariantNumeric: 'tabular-nums' }, children: [formatDuration(seek.shown ?? 0), " / ", formatDuration(duration ?? 0)] })) : null] })), _jsx(IconButton, { icon: volumeOpen ? 'lu:x' : muted ? 'lu:volume-x' : 'lu:volume-2', label: volumeOpen ? 'Close volume' : 'Volume', glyph: MEDIA_GLYPH, disabled: !ready || (caps?.volume === false && caps.mute === false), onClick: () => setVolumeOpen((current) => !current) }), open && browser !== undefined ? (_jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: MEDIA_GLYPH, onClick: openExpanded ?? open })) : null, _jsx(IconButton, { icon: "lu:skip-back", label: "Previous", glyph: MEDIA_GLYPH, disabled: !ready || caps?.previous === false, feedback: feedbackFor('previous'), onClick: () => press('previous', () => handle.command('previous')) }), _jsx(IconButton, { icon: playing ? 'lu:pause' : 'lu:play', label: playing ? 'Pause' : 'Play', glyph: MEDIA_GLYPH, disabled: !ready, primary: true, onClick: () => void handle.command('togglePlay') }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: MEDIA_GLYPH, disabled: !ready || caps?.next === false, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) }), timed ? (_jsx(SeekLine, { seek: seek, duration: duration ?? 0, seekable: caps?.seek === true })) : null] }));
159
+ // What the overlays hold, when the library and the queue open on their own (`overlays`).
160
+ const libraryBody = browse === false
161
+ ? undefined
162
+ : (browse ?? (ref && caps?.browse ? _jsx(MediaBrowser, { entity: ref, layout: "list" }) : undefined));
163
+ const queueBody = ref && caps?.queue === true ? _jsx(MediaQueue, { entity: ref }) : undefined;
164
+ const volumeSlider = (_jsxs(Flex, { as: motion.div, align: "center", gap: 2, grow: 1, minWidth: 0, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.15 }, "data-part": "volume", children: [_jsx(PlainButton, { as: motion.button, initial: { scale: 0, opacity: 0 }, animate: { scale: 1, opacity: 1 }, transition: VOLUME_SPRING, "aria-label": muted ? 'Unmute' : 'Mute', "aria-pressed": muted, title: muted ? 'Unmute' : 'Mute', onClick: volume.toggleMute, center: true, color: muted ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: muted ? 'lu:volume-x' : 'lu:volume-2', size: 16 }) }), _jsx(Flex, { as: motion.div, grow: 1, minWidth: 0, initial: { scaleX: 0.4, opacity: 0 }, animate: { scaleX: 1, opacity: 1 }, transition: { ...VOLUME_SPRING, delay: 0.04 }, css: { transformOrigin: 'left center' }, children: _jsx(ValueBar, { label: "Volume level", value: volume.shown, min: 0, max: 100, keyStep: 5, height: 10, onDrag: volume.drag, onCommit: volume.commit }) }), _jsxs(Typography, { as: motion.span, variant: "secondary", color: "textMuted", minWidth: 32, align: "right", css: { flex: 'none' }, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2, delay: 0.1 }, children: [volume.shown, "%"] })] }));
165
+ const trackInfo = (_jsx(_Fragment, { children: _jsxs(Flex, { "data-part": "track", direction: "column", grow: 1, minWidth: 0, ...(ready ? {} : { color: 'textMuted' }), children: [_jsxs(Flex, { align: "center", gap: 1.5, minWidth: 0, children: [_jsx(Typography, { as: "span", variant: "label", noWrap: true, textOverflow: "ellipsis", children: ready
166
+ ? (player?.media?.title ?? 'Nothing playing')
167
+ : statusLabels[status] }), ready && caps?.shuffle ? (_jsx(PlainButton, { "aria-label": "Shuffle", "aria-pressed": player?.shuffle === true, title: "Shuffle", onClick: () => void handle.command('setShuffle', { shuffle: player?.shuffle !== true }), center: true, color: player?.shuffle === true ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:shuffle", size: 14 }) })) : null] }), ready && player?.media?.artist ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", children: player.media.artist })) : null] }) }));
168
+ const bar = (open, openExpanded, openQueue) => (_jsxs(HoldCard, { open: open, status: status, rows: rows, volumeOpen: volumeOpen && ready, children: [_jsx(Ring, { onOpen: open, fraction: progress, children: artwork }), trackInfo, timed ? (_jsxs(Typography, { as: "span", "data-part": "time", variant: "secondary", color: "textMuted", noWrap: true, css: { flex: 'none', fontVariantNumeric: 'tabular-nums' }, children: [formatDuration(seek.shown ?? 0), " / ", formatDuration(duration ?? 0)] })) : null, volumeOpen && ready ? volumeSlider : null, _jsxs(Flex, { "data-part": "tools", align: "center", gap: 3, css: { flex: 'none' }, children: [_jsx(IconButton, { icon: volumeOpen ? 'lu:x' : muted ? 'lu:volume-x' : 'lu:volume-2', label: volumeOpen ? 'Close volume' : 'Volume', glyph: MEDIA_GLYPH, disabled: !ready || (caps?.volume === false && caps.mute === false), onClick: () => setVolumeOpen((current) => !current) }), (overlays
169
+ ? openExpanded !== undefined
170
+ : open && (onOpen ? browse !== false : browser !== undefined)) ? (_jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: MEDIA_GLYPH, onClick: () => (openExpanded ?? open)?.() })) : null, openQueue ? (_jsx(IconButton, { icon: "lu:list-music", label: "Queue", glyph: MEDIA_GLYPH, onClick: openQueue })) : null] }), _jsxs(Flex, { "data-part": "transport", align: "center", gap: 3, css: { flex: 'none' }, children: [_jsx(IconButton, { icon: "lu:skip-back", label: "Previous", glyph: MEDIA_GLYPH, disabled: !ready || caps?.previous === false, feedback: feedbackFor('previous'), onClick: () => press('previous', () => handle.command('previous')) }), _jsx(IconButton, { icon: playing ? 'lu:pause' : 'lu:play', label: playing ? 'Pause' : 'Play', glyph: MEDIA_GLYPH, disabled: !ready, primary: true, onClick: () => void handle.command('togglePlay') }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: MEDIA_GLYPH, disabled: !ready || caps?.next === false, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) })] }), timed ? (_jsx(SeekLine, { seek: seek, duration: duration ?? 0, seekable: caps?.seek === true })) : null] }));
76
171
  // Holding the card, the artwork and the browse button open the same drawer: the player laid out
77
172
  // like the vertical one, with the library below it.
78
- return ready ? (_jsx(DrawerTrigger, { icon: "lu:music", label: name ?? player?.name ?? fallbackName(entity), kind: "Media", body: _jsx(MediaPlayerFull, { entity: entity, ...(name !== undefined ? { name } : {}), ...(browser !== undefined ? { browser } : {}), ...(typeof entity === 'string' ? { queue: _jsx(MediaQueue, { entity: entity }) } : {}) }), children: (open, openExpanded) => bar(open, openExpanded) })) : (bar());
173
+ if (!ready) {
174
+ return bar();
175
+ }
176
+ // The library and the queue are overlays of their own; the card is the player.
177
+ if (overlays) {
178
+ const title = name ?? player?.name ?? fallbackName(entity);
179
+ return (_jsx(DrawerTrigger, { icon: "lu:library", label: "Library", kind: title, body: libraryBody ?? null, children: (_open, _expanded, openLibrary) => (_jsx(DrawerTrigger, { icon: "lu:list-music", label: "Queue", kind: title, body: queueBody ?? null, children: (_openToo, _expandedToo, openQueue) => bar(onOpen, libraryBody ? openLibrary : undefined, queueBody ? openQueue : undefined) })) }));
180
+ }
181
+ // Held, pressed or browsed, the player goes where the dashboard says instead of opening a drawer.
182
+ if (onOpen) {
183
+ return bar(onOpen, onOpen);
184
+ }
185
+ if (!drawer) {
186
+ return bar();
187
+ }
188
+ return (_jsx(DrawerTrigger, { icon: "lu:music", label: name ?? player?.name ?? fallbackName(entity), kind: "Media", body: _jsx(MediaPlayerFull, { entity: entity, ...(name !== undefined ? { name } : {}), ...(browser !== undefined ? { browser } : {}), ...(typeof entity === 'string' ? { queue: _jsx(MediaQueue, { entity: entity }) } : {}) }), children: (open, openExpanded) => bar(open, openExpanded) }));
79
189
  }
@@ -1,9 +1,12 @@
1
1
  /** The spot being sought to while a drag is in progress, and for a moment after it ends: the
2
2
  * player takes a beat to report the new position, and without this the bar would snap back to
3
- * the old one first. `shown` is what to draw; `preview` follows a drag; `commit` ends it. */
3
+ * the old one first. `shown` is what to draw; `preview` follows a drag; `commit` ends it and seeks;
4
+ * `cancel` ends it without seeking (a cancelled or lost touch). A drag that is neither committed nor
5
+ * cancelled expires on its own, so the position can never stay stuck. */
4
6
  export declare function useSeekHold(position: number | undefined, onSeek: ((position: number) => void) | undefined): {
5
7
  shown: number | undefined;
6
8
  preview: (next: number) => void;
7
9
  commit: (next: number) => void;
10
+ cancel: () => void;
8
11
  };
9
12
  export type SeekHold = ReturnType<typeof useSeekHold>;
@@ -1,19 +1,38 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
+ /** How long a drag may go without a word before it is given up on: a touch whose end never arrived
3
+ * (cancelled by the browser, the finger lifted off the screen) must not leave the position frozen. */
4
+ const IDLE_MS = 8000;
2
5
  /** The spot being sought to while a drag is in progress, and for a moment after it ends: the
3
6
  * player takes a beat to report the new position, and without this the bar would snap back to
4
- * the old one first. `shown` is what to draw; `preview` follows a drag; `commit` ends it. */
7
+ * the old one first. `shown` is what to draw; `preview` follows a drag; `commit` ends it and seeks;
8
+ * `cancel` ends it without seeking (a cancelled or lost touch). A drag that is neither committed nor
9
+ * cancelled expires on its own, so the position can never stay stuck. */
5
10
  export function useSeekHold(position, onSeek) {
6
11
  const [drag, setDrag] = useState(null);
7
12
  const release = useRef(undefined);
8
- useEffect(() => () => clearTimeout(release.current), []);
13
+ const idle = useRef(undefined);
14
+ useEffect(() => () => {
15
+ clearTimeout(release.current);
16
+ clearTimeout(idle.current);
17
+ }, []);
9
18
  return {
10
19
  shown: drag ?? position,
11
- preview: (next) => setDrag(next),
20
+ preview: (next) => {
21
+ setDrag(next);
22
+ clearTimeout(idle.current);
23
+ idle.current = setTimeout(() => setDrag(null), IDLE_MS);
24
+ },
12
25
  commit: (next) => {
13
26
  setDrag(next);
27
+ clearTimeout(idle.current);
14
28
  clearTimeout(release.current);
15
29
  release.current = setTimeout(() => setDrag(null), 1500);
16
30
  onSeek?.(next);
17
31
  },
32
+ cancel: () => {
33
+ clearTimeout(idle.current);
34
+ clearTimeout(release.current);
35
+ setDrag(null);
36
+ },
18
37
  };
19
38
  }
@@ -10,13 +10,13 @@ import { useThemeToggle } from '../provider.js';
10
10
  * surrounding `Page`, which pads for it. */
11
11
  export function NavDock({ items, base, showThemeToggle }) {
12
12
  usePageInset('bottom', 88);
13
- return (_jsxs(Flex, { as: "nav", "aria-label": "Pages", align: "center", gap: 1.5, background: "rail", radius: "full", p: 1.5, shadow: "drawer", position: "fixed", css: ({ spacing }) => ({
13
+ return (_jsxs(Flex, { as: "nav", "aria-label": "Pages", align: "center", gap: 1.5, background: "rail", radius: "full", p: 1.5, shadow: "dock", position: "fixed", css: ({ spacing }) => ({
14
14
  bottom: spacing(5),
15
15
  left: '50%',
16
16
  transform: 'translateX(-50%)',
17
- }), children: [items.map((item) => (_jsx(NavLink, { to: item.to ? `${base}/${item.to}` : base, end: true, "aria-label": item.label, title: item.label, children: ({ isActive }) => (_jsx(Flex, { as: "span", align: "center", justify: "center", width: 44, height: 44, radius: "chrome", cursor: "pointer", color: isActive ? 'accentText' : 'line', ...(isActive ? { background: 'accent' } : {}), children: _jsx(Icon, { name: item.icon, size: 22 }) })) }, item.to))), showThemeToggle ? _jsx(ThemeToggleButton, {}) : null] }));
17
+ }), children: [items.map((item) => (_jsx(NavLink, { to: item.to ? `${base}/${item.to}` : base, end: true, "aria-label": item.label, title: item.label, children: ({ isActive }) => (_jsx(Flex, { as: "span", align: "center", justify: "center", width: 44, height: 44, radius: "full", cursor: "pointer", color: isActive ? 'accentText' : 'line', ...(isActive ? { background: 'accent' } : {}), children: _jsx(Icon, { name: item.icon, size: 22 }) })) }, item.to))), showThemeToggle ? _jsx(ThemeToggleButton, {}) : null] }));
18
18
  }
19
19
  function ThemeToggleButton() {
20
20
  const { resolved, toggle } = useThemeToggle();
21
- return (_jsx(Flex, { as: "button", type: "button", onClick: toggle, "aria-label": `Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`, title: `Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`, align: "center", justify: "center", width: 44, height: 44, radius: "chrome", cursor: "pointer", color: "line", css: { background: 'none' }, children: _jsx(Icon, { name: resolved === 'dark' ? 'lu:moon' : 'lu:sun', size: 22 }) }));
21
+ return (_jsx(Flex, { as: "button", type: "button", onClick: toggle, "aria-label": `Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`, title: `Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`, align: "center", justify: "center", width: 44, height: 44, radius: "full", cursor: "pointer", color: "line", css: { background: 'none' }, children: _jsx(Icon, { name: resolved === 'dark' ? 'lu:moon' : 'lu:sun', size: 22 }) }));
22
22
  }
@@ -42,6 +42,18 @@ export function SeekLine({ seek, duration, seekable, }) {
42
42
  seek.commit(positionAt(event));
43
43
  }
44
44
  },
45
+ // A touch the browser took (a gesture, a scroll) or lost never ends with a release: give
46
+ // the position back to the player instead of leaving it where the finger was.
47
+ onPointerCancel: () => {
48
+ dragging.current = false;
49
+ seek.cancel();
50
+ },
51
+ onLostPointerCapture: () => {
52
+ if (dragging.current) {
53
+ dragging.current = false;
54
+ seek.cancel();
55
+ }
56
+ },
45
57
  }
46
58
  : { role: 'progressbar' }), "aria-label": "Position", "aria-valuemin": 0, "aria-valuemax": Math.round(duration), "aria-valuenow": Math.round(shown), css: ({ palette }) => ({
47
59
  left: 0,
@@ -58,6 +58,18 @@ export interface SystemStatusProps {
58
58
  * something connects, red when something is down; click for per-system detail. `TopBar` renders it
59
59
  * from live status, so it only needs the props above for demos. */
60
60
  export declare function SystemStatus({ link: linkOverride, statuses: statusesOverride, defaultOpen, }: SystemStatusProps): import("@emotion/react/jsx-runtime").JSX.Element;
61
+ export interface WeatherChipProps {
62
+ /** A weather entity, like `ha:weather.forecast_home`, or a plain temperature sensor. */
63
+ entity: EntityRef;
64
+ /** Tapping a weather entity's pill opens its forecast in a drawer. `false` builds none and the pill is only what it shows, which is lighter on a small display. Default `true`. */
65
+ drawer?: boolean;
66
+ }
67
+ /** Current weather, as a pill: an icon for the sky and a rounded whole-degree reading. A `weather`
68
+ * entity gives the real condition, today's high and low once its forecast has loaded, and opens the
69
+ * forecast when tapped (if its source has one); a plain `sensor.*` (an outdoor temperature) gets a
70
+ * fixed sun and no forecast, since a sensor says nothing about the sky. The top bar's weather pill,
71
+ * for a dashboard that builds its own header instead of using `TopBar`. */
72
+ export declare function WeatherChip({ entity, drawer }: WeatherChipProps): import("@emotion/react/jsx-runtime").JSX.Element;
61
73
  export interface PresenceStackProps {
62
74
  /** People, as refs like `ha:person.dan`: one avatar each (their picture, or a colored initial). Someone who is away is dimmed and grayscale. */
63
75
  entities: EntityRef[];
@@ -66,3 +78,12 @@ export interface PresenceStackProps {
66
78
  }
67
79
  /** Overlapping avatar circles for a handful of `person.*` entities. */
68
80
  export declare function PresenceStack({ entities, colors }: PresenceStackProps): import("@emotion/react/jsx-runtime").JSX.Element;
81
+ /** A short date pill (`Tue · Oct 6`), the top bar's, for a dashboard that builds its own header. */
82
+ export declare function DateChip(): import("@emotion/react/jsx-runtime").JSX.Element;
83
+ export interface ClockProps {
84
+ /** `'auto'` follows the browser's locale (browsers don't read the OS 12/24h setting, so an `en-US` browser shows AM/PM); `'12h'`/`'24h'` force it. Default `'auto'`. */
85
+ format?: 'auto' | '12h' | '24h';
86
+ }
87
+ /** The top bar's clock: the time in the large clock type, with AM/PM beside it in a 12h format. For
88
+ * a dashboard that builds its own header instead of using `TopBar`. */
89
+ export declare function Clock({ format }: ClockProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -138,12 +138,20 @@ function StatusChip({ children }) {
138
138
  return (_jsx(Flex, { align: "center", gap: 2, background: "surface", border: true, radius: "full", px: 3.5, py: 2.5, children: children }));
139
139
  }
140
140
  /** Current weather, as a pill: an icon for the sky and a rounded whole-degree reading. A `weather`
141
- * entity gives the real condition; a plain `sensor.*` (an outdoor temperature) gets a fixed sun,
142
- * since a sensor says nothing about the sky. */
143
- function WeatherChip({ entity }) {
144
- return useEntity(entity)?.kind === 'weather' ? (_jsx(WeatherPill, { entity: entity })) : (_jsx(SensorWeather, { entity: entity }));
141
+ * entity gives the real condition, today's high and low once its forecast has loaded, and opens the
142
+ * forecast when tapped (if its source has one); a plain `sensor.*` (an outdoor temperature) gets a
143
+ * fixed sun and no forecast, since a sensor says nothing about the sky. The top bar's weather pill,
144
+ * for a dashboard that builds its own header instead of using `TopBar`. */
145
+ export function WeatherChip({ entity, drawer = true }) {
146
+ return useEntity(entity)?.kind === 'weather' ? (_jsx(WeatherPill, { entity: entity, drawer: drawer })) : (_jsx(SensorWeather, { entity: entity }));
145
147
  }
146
- function WeatherPill({ entity }) {
148
+ /** Text boxes cut to the height of the digits, so the reading and the range, in different sizes, are
149
+ * both centred on their digits and not on the font's line box (which sits a little low or high).
150
+ * Where the browser does not know `text-box` the text keeps its usual box. */
151
+ /** The height of one line of the 12px label text. */
152
+ const LABEL_LINE = 15;
153
+ const TRIM_TO_DIGITS = { textBox: 'trim-both cap alphabetic' };
154
+ function WeatherPill({ entity, drawer }) {
147
155
  const handle = useEntityHandle('weather', entity);
148
156
  const { status } = handle;
149
157
  const weather = handle.entity;
@@ -157,10 +165,10 @@ function WeatherPill({ entity }) {
157
165
  const range = today?.temperature !== undefined && today.low !== undefined
158
166
  ? `${Math.round(today.temperature)}° / ${Math.round(today.low)}°`
159
167
  : undefined;
160
- const chip = (_jsxs(StatusChip, { children: [_jsx(Flex, { as: "span", children: _jsx(WeatherIcon, { condition: condition, size: 14 }) }), _jsx(Typography, { as: "span", variant: "label", children: ready
168
+ const chip = (_jsxs(StatusChip, { children: [_jsx(Flex, { as: "span", css: { minHeight: LABEL_LINE }, children: _jsx(WeatherIcon, { condition: condition, size: 14 }) }), _jsx(Typography, { as: "span", variant: "label", css: TRIM_TO_DIGITS, children: ready
161
169
  ? `${Math.round(weather.temperature ?? 0)}°`
162
- : statusLabels[status] }), ready && range ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: range })) : null] }));
163
- if (!forecastable) {
170
+ : statusLabels[status] }), ready && range ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", css: TRIM_TO_DIGITS, children: range })) : null] }));
171
+ if (!forecastable || !drawer) {
164
172
  return chip;
165
173
  }
166
174
  return (_jsx(DrawerTrigger, { icon: look.icon, label: weather?.name ?? 'Weather', kind: "Weather", body: _jsx(WeatherForecast, { entity: entity }), children: (open) => (_jsx(PlainButton, { "aria-label": `${weather?.name ?? 'Weather'}: forecast`, onClick: open, children: chip })) }));
@@ -238,13 +246,16 @@ function useNow(intervalMs) {
238
246
  }, [intervalMs]);
239
247
  return now;
240
248
  }
241
- function DateChip() {
249
+ /** A short date pill (`Tue · Oct 6`), the top bar's, for a dashboard that builds its own header. */
250
+ export function DateChip() {
242
251
  const now = useNow(60_000);
243
252
  const weekday = now.toLocaleDateString(undefined, { weekday: 'short' });
244
253
  const day = now.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
245
254
  return (_jsx(StatusChip, { children: _jsxs(Typography, { as: "span", variant: "label", children: [weekday, " \u00B7 ", day] }) }));
246
255
  }
247
- function Clock({ format }) {
256
+ /** The top bar's clock: the time in the large clock type, with AM/PM beside it in a 12h format. For
257
+ * a dashboard that builds its own header instead of using `TopBar`. */
258
+ export function Clock({ format = 'auto' }) {
248
259
  const now = useNow(30_000);
249
260
  const { time, dayPeriod } = formatClock(now, format);
250
261
  return (_jsxs(Flex, { align: "flex-start", gap: 1, children: [_jsx(Typography, { as: "span", variant: "clock", children: time }), dayPeriod ? (_jsx(Typography, { as: "span", variant: "label", css: { textTransform: 'uppercase' }, children: dayPeriod })) : null] }));