@hashsome/ui 0.4.0 → 0.6.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.
Files changed (59) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/debug.d.ts +27 -0
  3. package/dist/debug.js +95 -0
  4. package/dist/entities/artwork-ring.d.ts +4 -2
  5. package/dist/entities/artwork-ring.js +79 -51
  6. package/dist/entities/light-tile.d.ts +3 -1
  7. package/dist/entities/light-tile.js +29 -20
  8. package/dist/entities/media-browser.d.ts +3 -1
  9. package/dist/entities/media-browser.js +41 -15
  10. package/dist/entities/media-player-bar.d.ts +9 -1
  11. package/dist/entities/media-player-bar.js +122 -12
  12. package/dist/entities/media-player-column.d.ts +5 -2
  13. package/dist/entities/media-player-column.js +33 -5
  14. package/dist/entities/media-player-full.d.ts +12 -2
  15. package/dist/entities/media-player-full.js +45 -3
  16. package/dist/entities/media-seek.d.ts +4 -1
  17. package/dist/entities/media-seek.js +22 -3
  18. package/dist/entities/nav-dock.js +3 -3
  19. package/dist/entities/now-playing.d.ts +3 -1
  20. package/dist/entities/now-playing.js +8 -3
  21. package/dist/entities/seek-line.js +12 -0
  22. package/dist/entities/top-bar.d.ts +21 -0
  23. package/dist/entities/top-bar.js +19 -10
  24. package/dist/gallery/gallery.js +18 -3
  25. package/dist/gallery/props-data.js +187 -3
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.js +3 -0
  28. package/dist/layout/animated-outlet.d.ts +14 -0
  29. package/dist/layout/animated-outlet.js +44 -0
  30. package/dist/layout/board.d.ts +26 -0
  31. package/dist/layout/board.js +85 -0
  32. package/dist/layout/debug-menu.d.ts +11 -0
  33. package/dist/layout/debug-menu.js +99 -0
  34. package/dist/layout/detail-provider.d.ts +4 -2
  35. package/dist/layout/detail-provider.js +2 -2
  36. package/dist/layout/drawer-controls.js +36 -8
  37. package/dist/layout/entity-drawer.js +1 -1
  38. package/dist/layout/grid-overlay.d.ts +6 -0
  39. package/dist/layout/grid-overlay.js +143 -0
  40. package/dist/layout/hold-progress.d.ts +8 -2
  41. package/dist/layout/hold-progress.js +20 -4
  42. package/dist/layout/page.d.ts +7 -1
  43. package/dist/layout/page.js +43 -6
  44. package/dist/layout/room-header.js +1 -1
  45. package/dist/layout/tile.js +14 -10
  46. package/dist/layout/top-row.d.ts +9 -0
  47. package/dist/layout/top-row.js +10 -0
  48. package/dist/layout/use-drawer.d.ts +3 -2
  49. package/dist/layout/use-drawer.js +5 -3
  50. package/dist/provider.d.ts +15 -4
  51. package/dist/provider.js +144 -13
  52. package/dist/theme/global-styles.js +3 -0
  53. package/dist/theme/grid.d.ts +30 -0
  54. package/dist/theme/grid.js +38 -0
  55. package/dist/theme/index.js +2 -1
  56. package/dist/theme/schedule.d.ts +33 -0
  57. package/dist/theme/schedule.js +44 -0
  58. package/dist/theme/tokens.d.ts +2 -0
  59. package/package.json +2 -2
@@ -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-grid-card": true, "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
  }
@@ -9,10 +9,13 @@ export interface MediaPlayerColumnProps {
9
9
  name?: string;
10
10
  /** Content for the media browser shown with the player in the drawer. 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 drawer. */
11
11
  browse?: ReactNode | false;
12
+ /** The library and the queue each open as a full-size overlay of their own, from a library button and a queue button beside the transport, instead of one half-size drawer with the whole player in it: for a small display, where the drawer's half state is no use. 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. Shuffle moves up beside the title. Default `false`. */
13
+ overlays?: boolean;
12
14
  }
13
15
  /** The player as an upright card for a narrow column beside a dashboard (a quarter to a third of
14
16
  * the width): the artwork in a ring that shows (and seeks) the position, title, transport and a
15
17
  * volume bar that is always visible. It is only as tall as it needs to be. Holding the card or
16
18
  * pressing the artwork opens the drawer with the player and its library; the browse button opens
17
- * that drawer expanded. */
18
- export declare function MediaPlayerColumn({ entity, name, browse }: MediaPlayerColumnProps): import("@emotion/react/jsx-runtime").JSX.Element;
19
+ * that drawer expanded. With `overlays` there is no drawer: the library and the queue open full
20
+ * size, each on its own. */
21
+ export declare function MediaPlayerColumn({ entity, name, browse, overlays, }: MediaPlayerColumnProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -14,8 +14,9 @@ import { NowPlaying } from './now-playing.js';
14
14
  * the width): the artwork in a ring that shows (and seeks) the position, title, transport and a
15
15
  * volume bar that is always visible. It is only as tall as it needs to be. Holding the card or
16
16
  * pressing the artwork opens the drawer with the player and its library; the browse button opens
17
- * that drawer expanded. */
18
- export function MediaPlayerColumn({ entity, name, browse }) {
17
+ * that drawer expanded. With `overlays` there is no drawer: the library and the queue open full
18
+ * size, each on its own. */
19
+ export function MediaPlayerColumn({ entity, name, browse, overlays = false, }) {
19
20
  const handle = useEntityHandle('mediaPlayer', entity);
20
21
  const player = handle.entity;
21
22
  const ref = typeof entity === 'string' ? entity : undefined;
@@ -24,12 +25,39 @@ export function MediaPlayerColumn({ entity, name, browse }) {
24
25
  : (browse ??
25
26
  (ref && player?.capabilities.browse ? (_jsx(MediaBrowser, { entity: ref, layout: "auto" })) : undefined));
26
27
  const fallback = fallbackName(entity);
28
+ if (overlays) {
29
+ // What the overlays hold: the library as a list, the queue; each only when the player has one.
30
+ const title = name ?? player?.name ?? fallback;
31
+ const libraryBody = browse === false
32
+ ? undefined
33
+ : (browse ??
34
+ (ref && player?.capabilities.browse ? (_jsx(MediaBrowser, { entity: ref, layout: "list" })) : undefined));
35
+ const queueBody = ref && player?.capabilities.queue === true ? _jsx(MediaQueue, { entity: ref }) : undefined;
36
+ return handle.status === 'ready' ? (_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) => (_jsx(Card, { children: _jsx(NowPlaying, { handle: handle, fallback: fallback, shuffle: "title", ...(name !== undefined ? { name } : {}), ...(libraryBody
37
+ ? {
38
+ leading: (_jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: 18, onClick: openLibrary })),
39
+ }
40
+ : {}), ...(queueBody
41
+ ? {
42
+ extra: (_jsx(IconButton, { icon: "lu:list-music", label: "Queue", glyph: 18, onClick: openQueue })),
43
+ }
44
+ : {}) }) })) })) })) : (_jsx(Card, { children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}) }) }));
45
+ }
27
46
  return browser !== undefined && handle.status === 'ready' ? (_jsx(DrawerTrigger, { icon: "lu:music", label: name ?? player?.name ?? fallback, kind: "Media", body: _jsx(MediaPlayerFull, { entity: entity, browser: browser, ...(typeof entity === 'string' ? { queue: _jsx(MediaQueue, { entity: entity }) } : {}), ...(name !== undefined ? { name } : {}) }), children: (open, openExpanded) => (_jsx(Card, { open: open, children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}), onOpenArtwork: open, extra: _jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: 18, onClick: openExpanded }) }) })) })) : (_jsx(Card, { children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}) }) }));
28
47
  }
29
48
  /** The card itself; with `open`, holding it (anywhere but on a control) calls it. */
30
49
  function Card({ open, children }) {
31
50
  const hold = useHold(() => open?.(), open !== undefined);
32
- return (_jsxs(Flex, { direction: "column", background: "surface", radius: "card", p: 4, width: "100%",
33
- // Only as tall as it needs, even in a parent that stretches its children.
34
- css: { alignSelf: 'flex-start', position: 'relative', overflow: 'hidden' }, ...hold.handlers, children: [children, hold.holding ? _jsx(HoldProgress, {}) : null] }));
51
+ return (_jsxs(Flex, { "data-grid-card": true, direction: "column", background: "surface", radius: "card", p: 4, width: "100%",
52
+ // Only as tall as it needs, even in a parent that stretches its children, and no taller than
53
+ // the parent: the artwork gives way.
54
+ css: {
55
+ alignSelf: 'flex-start',
56
+ position: 'relative',
57
+ overflow: 'hidden',
58
+ maxHeight: '100%',
59
+ minHeight: 0,
60
+ // Stretched beside other cards, the artwork takes the room and the controls stay at the bottom.
61
+ '& > [data-status]': { flex: '1 1 auto' },
62
+ }, ...hold.handlers, children: [children, hold.holding ? _jsx(HoldProgress, {}) : null] }));
35
63
  }
@@ -1,6 +1,6 @@
1
1
  /** @jsxImportSource @emotion/react */
2
2
  import type { EntityRef } from '@hashsome/core';
3
- import type { ReactNode } from 'react';
3
+ import { type ReactNode } from 'react';
4
4
  import { type EntityHandle } from '../entity-handle.ts';
5
5
  export interface MediaPlayerFullProps {
6
6
  /** The media player, as a ref like `ma:living_room`. */
@@ -13,10 +13,20 @@ export interface MediaPlayerFullProps {
13
13
  name?: string;
14
14
  /** Gives the library the whole width, for one laid out to use it (the theater layout). Defaults to whether the drawer is expanded. */
15
15
  wide?: boolean;
16
+ /** In the narrow layout, where the queue has no room beside the player, a Library and a Queue tab above the list switch between them (shown when there is a `browser`, a `queue` and a player with a queue). `false` leaves the library alone, as before. In the wide layout the queue is beside the player and there are no tabs. Default `true`. */
17
+ tabs?: boolean;
18
+ /** The tab that is open first, when the tabs are on. Default `'library'`. */
19
+ defaultTab?: ListTab;
20
+ /** The open tab, to keep it yourself (to remember it between visits, or open the queue from elsewhere); the tabs ask for a change through `onTabChange`. Without it, the component keeps it, until it is gone. */
21
+ tab?: ListTab;
22
+ /** Called with the tab someone chose. */
23
+ onTabChange?: (tab: ListTab) => void;
16
24
  }
25
+ /** What the tabs below the player choose between. */
26
+ export type ListTab = 'library' | 'queue';
17
27
  /** The big player: the player centered at the top, and the library (or whatever `browser` is) below
18
28
  * it. It is what the media cards' drawers show, at every width, and it is the widget a dashboard puts
19
29
  * in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
20
30
  * heading, other cards beside it). It fills the height it is given, so the page that holds it
21
31
  * decides how tall that is. */
22
- export declare function MediaPlayerFull({ entity, browser, queue, wide, name }: MediaPlayerFullProps): import("@emotion/react/jsx-runtime").JSX.Element;
32
+ export declare function MediaPlayerFull({ entity, browser, queue, wide, name, tabs, defaultTab, tab, onTabChange, }: MediaPlayerFullProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -1,19 +1,61 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
- import { Box, Flex } from 'e-prim';
2
+ import { Box, Flex, Typography } from 'e-prim';
3
+ import { motion } from 'motion/react';
4
+ import { useId, useState } from 'react';
3
5
  import { fallbackName } from '../entity-handle.js';
4
6
  import { useEntityHandle } from '../hooks.js';
5
7
  import { useDetail } from '../layout/detail-provider.js';
8
+ import { Icon } from '../icon.js';
6
9
  import { NowPlaying } from './now-playing.js';
7
10
  /** The artwork ring's diameter in a full-size player, against the usual 168px. */
8
11
  const FULL_SIZE = 260;
12
+ const TAB_HEIGHT = 40;
13
+ /** Library and Queue, as text with an underline (not the pills the library's own categories are, so the
14
+ * two rows read as different things). */
15
+ function ListTabs({ value, onChange }) {
16
+ // One line that slides between the tabs; its own id, so two players on a page do not share it.
17
+ const line = useId();
18
+ const tabs = [
19
+ { id: 'library', label: 'Library', icon: 'lu:library' },
20
+ { id: 'queue', label: 'Queue', icon: 'lu:list-music' },
21
+ ];
22
+ return (_jsx(Flex, { role: "tablist", "aria-label": "Library or queue", gap: 5, css: ({ palette }) => ({ flexShrink: 0, borderBottom: `1px solid ${palette.border}` }), children: tabs.map((tab) => {
23
+ const selected = tab.id === value;
24
+ return (_jsxs(Flex, { as: "button", type: "button", role: "tab", "aria-selected": selected, onClick: () => onChange(tab.id), align: "center", justify: "center", gap: 2, height: TAB_HEIGHT, cursor: "pointer", color: selected ? 'text' : 'textMuted', css: {
25
+ flex: 1,
26
+ background: 'none',
27
+ border: 0,
28
+ padding: 0,
29
+ position: 'relative',
30
+ marginBottom: -1,
31
+ }, children: [selected ? (_jsx(Box, { as: motion.span, layoutId: line, position: "absolute", background: "accent", transition: { duration: 0.2, ease: 'easeOut' }, css: { left: 0, right: 0, bottom: 0, height: 2 } })) : null, _jsx(Icon, { name: tab.icon, size: 16 }), _jsx(Typography, { as: "span", variant: selected ? 'bodyStrong' : 'body', children: tab.label })] }, tab.id));
32
+ }) }));
33
+ }
9
34
  /** The big player: the player centered at the top, and the library (or whatever `browser` is) below
10
35
  * it. It is what the media cards' drawers show, at every width, and it is the widget a dashboard puts
11
36
  * in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
12
37
  * heading, other cards beside it). It fills the height it is given, so the page that holds it
13
38
  * decides how tall that is. */
14
- export function MediaPlayerFull({ entity, browser, queue, wide, name }) {
39
+ export function MediaPlayerFull({ entity, browser, queue, wide, name, tabs = true, defaultTab = 'library', tab, onTabChange, }) {
15
40
  const handle = useEntityHandle('mediaPlayer', entity);
16
41
  const { detail } = useDetail();
17
42
  const full = wide ?? detail?.expanded === true;
18
- return (_jsxs(Flex, { direction: "column", gap: 5, grow: 1, minHeight: 0, children: [_jsxs(Flex, { align: "stretch", justify: "center", gap: 6, ...(full ? { grow: 1 } : {}), css: { flexShrink: 0 }, children: [_jsx(Flex, { align: "center", justify: "center", grow: 1, minWidth: 0, children: _jsx(Box, { width: "100%", maxWidth: full ? 560 : 420, children: _jsx(NowPlaying, { handle: handle, fallback: fallbackName(entity), ...(name !== undefined ? { name } : {}), shuffle: "title", ...(full ? { size: FULL_SIZE } : {}) }) }) }), full && queue !== undefined && handle.entity?.capabilities.queue === true ? (_jsx(Flex, { position: "relative", width: 400, css: ({ palette }) => ({ flex: 'none', borderLeft: `1px solid ${palette.border}` }), children: _jsx(Flex, { direction: "column", position: "absolute", pl: 5, css: { inset: 0 }, children: queue }) })) : null] }), browser !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Box, { height: 1, background: "border", css: { flexShrink: 0 } }), _jsx(Flex, { direction: "column", width: "100%", minHeight: 0, ...(full ? {} : { maxWidth: 960, mx: 'auto' }), children: browser })] })) : null] }));
43
+ // Narrow, the queue and the library take turns in the one space below the player.
44
+ const [kept, setKept] = useState(defaultTab);
45
+ const hasQueue = queue !== undefined && handle.entity?.capabilities.queue === true;
46
+ const lists = [
47
+ ...(browser !== undefined ? ['library'] : []),
48
+ ...(!full && tabs && hasQueue ? ['queue'] : []),
49
+ ];
50
+ const wanted = tab ?? kept;
51
+ const open = lists.includes(wanted) ? wanted : lists[0];
52
+ const choose = (next) => {
53
+ setKept(next);
54
+ onTabChange?.(next);
55
+ };
56
+ return (_jsxs(Flex, { direction: "column", gap: 5, grow: 1, minHeight: 0, children: [_jsxs(Flex, { align: "stretch", justify: "center", gap: 6, ...(full ? { grow: 1 } : {}), css: { flexShrink: 0 }, children: [_jsx(Flex, { align: "center", justify: "center", grow: 1, minWidth: 0, children: _jsx(Box, { width: "100%", maxWidth: full ? 560 : 420, children: _jsx(NowPlaying, { handle: handle, fallback: fallbackName(entity), ...(name !== undefined ? { name } : {}), shuffle: "title", ...(full ? { size: FULL_SIZE } : {}) }) }) }), full && queue !== undefined && handle.entity?.capabilities.queue === true ? (_jsx(Flex, { position: "relative", width: 400, css: ({ palette }) => ({ flex: 'none', borderLeft: `1px solid ${palette.border}` }), children: _jsx(Flex, { direction: "column", position: "absolute", pl: 5, css: { inset: 0 }, children: queue }) })) : null] }), open !== undefined ? (_jsxs(_Fragment, { children: [lists.length > 1 ? (_jsx(ListTabs, { value: open, onChange: choose })) : (_jsx(Box, { height: 1, background: "border", css: { flexShrink: 0 } })), _jsx(Flex
57
+ // Another list fades in where the last one was, so a tab does not cut from one to the other.
58
+ , {
59
+ // Another list fades in where the last one was, so a tab does not cut from one to the other.
60
+ as: motion.div, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.15, ease: 'easeOut' }, direction: "column", width: "100%", minHeight: 0, ...(full ? {} : { maxWidth: 960, mx: 'auto' }), children: open === 'queue' ? queue : browser }, open)] })) : null] }));
19
61
  }
@@ -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
  }
@@ -3,7 +3,7 @@ import type { EntityHandle } from '../entity-handle.ts';
3
3
  /** The upright "now playing" panel the media column and page share: the artwork on a record, what is playing, how
4
4
  * far along it is, the transport buttons and a volume bar that is always there. Not exported from
5
5
  * the package; `MediaPlayerColumn` and `MediaPlayerFull` are its public forms. */
6
- export declare function NowPlaying({ handle, fallback, name, extra, onOpenArtwork, size, shuffle: shufflePlacement, }: {
6
+ export declare function NowPlaying({ handle, fallback, name, extra, leading, onOpenArtwork, size, shuffle: shufflePlacement, }: {
7
7
  handle: EntityHandle<'mediaPlayer'>;
8
8
  /** What to call the player before it reports a name. */
9
9
  fallback: string;
@@ -11,6 +11,8 @@ export declare function NowPlaying({ handle, fallback, name, extra, onOpenArtwor
11
11
  name?: string;
12
12
  /** One more button, to the right of the transport (the browse button). Shuffle takes the slot on the left, unless it sits by the title. */
13
13
  extra?: ReactNode;
14
+ /** One more button, to the left of the transport, in the slot shuffle has when it sits by the title. Not shown while shuffle is in the row. */
15
+ leading?: ReactNode;
14
16
  /** Makes the artwork a button that calls this (opens the library). */
15
17
  onOpenArtwork?: () => void;
16
18
  /** The artwork ring's diameter in px, for a roomier player. Default 168. */
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
2
  /** @jsxImportSource @emotion/react */
3
+ import { useTheme } from '@emotion/react';
3
4
  import { Flex, Grid, Typography } from 'e-prim';
4
5
  import { Icon } from '../icon.js';
5
6
  import { ValueBar } from '../layout/drawer-controls.js';
@@ -13,8 +14,10 @@ import { ArtworkRing } from './artwork-ring.js';
13
14
  /** The upright "now playing" panel the media column and page share: the artwork on a record, what is playing, how
14
15
  * far along it is, the transport buttons and a volume bar that is always there. Not exported from
15
16
  * the package; `MediaPlayerColumn` and `MediaPlayerFull` are its public forms. */
16
- export function NowPlaying({ handle, fallback, name, extra, onOpenArtwork, size, shuffle: shufflePlacement = 'transport', }) {
17
+ export function NowPlaying({ handle, fallback, name, extra, leading, onOpenArtwork, size, shuffle: shufflePlacement = 'transport', }) {
17
18
  const player = handle.entity;
19
+ const { iconCircle, space } = useTheme().density;
20
+ const transportWidth = 5 * iconCircle + 4 * space;
18
21
  const { status } = handle;
19
22
  const ready = status === 'ready';
20
23
  const caps = player?.capabilities;
@@ -25,10 +28,12 @@ export function NowPlaying({ handle, fallback, name, extra, onOpenArtwork, size,
25
28
  const duration = ready ? player?.duration : undefined;
26
29
  // The ring and the time under the title share one position, so dragging the ring moves both.
27
30
  const seek = useSeekHold(ready ? position : undefined, caps?.seek ? (next) => void handle.command('seek', { position: next }) : undefined);
28
- return (_jsxs(Flex, { direction: "column", gap: 4, "data-status": status, children: [_jsx(ArtworkRing, { artworkUrl: player?.media?.artworkUrl, seek: seek, duration: duration, seekable: caps?.seek === true, onOpen: onOpenArtwork, ...(size !== undefined ? { size } : {}) }), _jsxs(Flex, { direction: "column", align: "center", gap: 0.5, color: ready ? 'text' : 'textMuted', children: [_jsxs(Flex, { align: "center", justify: "center", gap: 2, maxWidth: "100%", children: [_jsx(Typography, { as: "h2", variant: "title", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", m: 0, minWidth: 0, children: ready
31
+ return (_jsxs(Flex, { direction: "column", gap: 4, "data-status": status, css: { minHeight: 0 }, children: [_jsx(ArtworkRing, { artworkUrl: player?.media?.artworkUrl, seek: seek, duration: duration, seekable: caps?.seek === true, onOpen: onOpenArtwork, ...(size !== undefined ? { size } : {}),
32
+ // No wider than the row of five buttons under it (268px: five circles and the gaps between them).
33
+ maxSize: transportWidth }), _jsxs(Flex, { direction: "column", align: "center", gap: 0.5, color: ready ? 'text' : 'textMuted', children: [_jsxs(Flex, { align: "center", justify: "center", gap: 2, maxWidth: "100%", children: [_jsx(Typography, { as: "h2", variant: "title", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", m: 0, minWidth: 0, children: ready
29
34
  ? (player?.media?.title ?? 'Nothing playing')
30
35
  : statusLabels[status] }), caps?.shuffle && shufflePlacement === 'title' ? (_jsx(IconButton, { icon: "lu:shuffle", label: "Shuffle", size: 14, active: player?.shuffle === true, pressed: player?.shuffle === true, disabled: !ready, onClick: () => void handle.command('setShuffle', { shuffle: player?.shuffle !== true }) })) : null] }), ready && player?.media?.artist ? (_jsx(Typography, { as: "span", variant: "body", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: [player.media.artist, player.media.album].filter(Boolean).join(' · ') })) : (_jsx(Typography, { as: "span", variant: "body", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: name ?? player?.name ?? fallback }))] }), seek.shown !== undefined && duration ? (_jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", align: "center", css: { fontVariantNumeric: 'tabular-nums' }, children: [formatDuration(seek.shown), " / ", formatDuration(duration)] })) : null, _jsxs(Grid, { gap: 3, css: ({ density }) => ({
31
36
  gridTemplateColumns: `repeat(5, ${density.iconCircle}px)`,
32
37
  justifyContent: 'center',
33
- }), children: [caps?.shuffle && shufflePlacement === 'transport' ? (_jsx(IconButton, { icon: "lu:shuffle", label: "Shuffle", glyph: 18, active: player?.shuffle === true, pressed: player?.shuffle === true, disabled: !ready, onClick: () => void handle.command('setShuffle', { shuffle: player?.shuffle !== true }) })) : (_jsx("span", {})), _jsx(IconButton, { icon: "lu:skip-back", label: "Previous", glyph: 18, 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: 18, disabled: !ready, primary: true, onClick: () => void handle.command('togglePlay') }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: 18, disabled: !ready || caps?.next === false, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) }), extra ?? _jsx("span", {})] }), caps?.volume !== false || caps.mute ? (_jsxs(Flex, { align: "center", gap: 2, children: [_jsx(PlainButton, { "aria-label": volume.muted ? 'Unmute' : 'Mute', "aria-pressed": volume.muted, title: volume.muted ? 'Unmute' : 'Mute', disabled: !ready || caps?.mute === false, onClick: volume.toggleMute, center: true, color: volume.muted ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: volume.muted ? 'lu:volume-x' : 'lu:volume-2', size: 16 }) }), _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: "span", variant: "secondary", color: "textMuted", minWidth: 32, align: "right", css: { flex: 'none', fontVariantNumeric: 'tabular-nums' }, children: [volume.shown, "%"] })] })) : null] }));
38
+ }), children: [caps?.shuffle && shufflePlacement === 'transport' ? (_jsx(IconButton, { icon: "lu:shuffle", label: "Shuffle", glyph: 18, active: player?.shuffle === true, pressed: player?.shuffle === true, disabled: !ready, onClick: () => void handle.command('setShuffle', { shuffle: player?.shuffle !== true }) })) : shufflePlacement === 'title' ? ((leading ?? _jsx("span", {}))) : (_jsx("span", {})), _jsx(IconButton, { icon: "lu:skip-back", label: "Previous", glyph: 18, 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: 18, disabled: !ready, primary: true, onClick: () => void handle.command('togglePlay') }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: 18, disabled: !ready || caps?.next === false, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) }), extra ?? _jsx("span", {})] }), caps?.volume !== false || caps.mute ? (_jsxs(Flex, { align: "center", gap: 2, children: [_jsx(PlainButton, { "aria-label": volume.muted ? 'Unmute' : 'Mute', "aria-pressed": volume.muted, title: volume.muted ? 'Unmute' : 'Mute', disabled: !ready || caps?.mute === false, onClick: volume.toggleMute, center: true, color: volume.muted ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: volume.muted ? 'lu:volume-x' : 'lu:volume-2', size: 16 }) }), _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: "span", variant: "secondary", color: "textMuted", minWidth: 32, align: "right", css: { flex: 'none', fontVariantNumeric: 'tabular-nums' }, children: [volume.shown, "%"] })] })) : null] }));
34
39
  }
@@ -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;