@hashsome/ui 0.7.0 → 0.9.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 (46) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/entities/attention-dot.d.ts +4 -0
  3. package/dist/entities/attention-dot.js +12 -0
  4. package/dist/entities/media-browser.js +35 -9
  5. package/dist/entities/media-controls.d.ts +22 -4
  6. package/dist/entities/media-controls.js +45 -10
  7. package/dist/entities/media-loading-line.d.ts +9 -0
  8. package/dist/entities/media-loading-line.js +14 -0
  9. package/dist/entities/media-pending.d.ts +24 -0
  10. package/dist/entities/media-pending.js +121 -0
  11. package/dist/entities/media-player-bar.js +10 -6
  12. package/dist/entities/media-progress.d.ts +4 -0
  13. package/dist/entities/media-progress.js +41 -0
  14. package/dist/entities/nav-dock.js +2 -1
  15. package/dist/entities/nav-rail.d.ts +2 -0
  16. package/dist/entities/nav-rail.js +4 -2
  17. package/dist/entities/now-playing.js +9 -6
  18. package/dist/entities/top-bar.d.ts +1 -1
  19. package/dist/entities/top-bar.js +24 -11
  20. package/dist/gallery/gallery.js +27 -1
  21. package/dist/gallery/props-data.js +106 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +2 -0
  24. package/dist/layout/check-mark.d.ts +5 -0
  25. package/dist/layout/check-mark.js +9 -0
  26. package/dist/layout/confirm-dialog.d.ts +28 -0
  27. package/dist/layout/confirm-dialog.js +62 -0
  28. package/dist/layout/debug-menu.js +10 -5
  29. package/dist/layout/drawer-controls.js +46 -3
  30. package/dist/layout/fade-scroll.d.ts +2 -0
  31. package/dist/layout/fade-scroll.js +1 -1
  32. package/dist/layout/icon-button-styles.js +3 -1
  33. package/dist/layout/spinner.d.ts +16 -0
  34. package/dist/layout/spinner.js +23 -0
  35. package/dist/layout/task-tile.d.ts +28 -0
  36. package/dist/layout/task-tile.js +78 -0
  37. package/dist/layout/tile.d.ts +1 -1
  38. package/dist/layout/tile.js +3 -1
  39. package/dist/layout/use-timed.d.ts +7 -0
  40. package/dist/layout/use-timed.js +22 -0
  41. package/dist/theme/grid.d.ts +3 -0
  42. package/dist/theme/grid.js +3 -0
  43. package/dist/theme/index.js +3 -0
  44. package/dist/theme/tokens.d.ts +4 -0
  45. package/dist/use-media-queue.js +5 -2
  46. package/package.json +2 -2
@@ -0,0 +1,4 @@
1
+ /** The dot at the corner of a nav item: its ring is the rail's own color, so it reads as cut out of the button. */
2
+ export declare function AttentionDot({ level }: {
3
+ level: 'notice' | 'urgent';
4
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { Flex } from 'e-prim';
4
+ /** The dot at the corner of a nav item: its ring is the rail's own color, so it reads as cut out of the button. */
5
+ export function AttentionDot({ level }) {
6
+ return (_jsx(Flex, { as: "span", "aria-hidden": "true", position: "absolute", width: 10, height: 10, radius: "full", css: ({ palette }) => ({
7
+ top: 6,
8
+ right: 6,
9
+ background: level === 'urgent' ? palette.danger : palette.warm,
10
+ boxShadow: `0 0 0 2px ${palette.rail}`,
11
+ }) }));
12
+ }
@@ -10,10 +10,12 @@ import { CHIP_HEIGHT, ChipRow } from '../layout/drawer-controls.js';
10
10
  import { FadeScroll } from '../layout/fade-scroll.js';
11
11
  import { MarqueeText } from '../layout/marquee-text.js';
12
12
  import { PlainButton } from '../layout/plain-button.js';
13
+ import { Spinner } from '../layout/spinner.js';
13
14
  import { Reveal } from '../layout/reveal.js';
14
15
  import { Skeleton } from '../layout/skeleton.js';
15
16
  import { IconButton } from '../layout/tile.js';
16
17
  import { useMediaBrowser } from '../use-media-browser.js';
18
+ import { trackMedia, useMediaPending } from './media-pending.js';
17
19
  /** The height of the search field, and of the row of tabs it replaces. */
18
20
  const SEARCH_HEIGHT = 36;
19
21
  const KIND_ICONS = {
@@ -39,6 +41,11 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
39
41
  const player = handle.entity;
40
42
  // A player with a queue can be told to add to it, play next and shuffle; one without can only play.
41
43
  const queueable = player?.capabilities.queue === true;
44
+ /** Asking the player to play something can take it many seconds; until it answers, the item and the
45
+ * player say so. */
46
+ const waiting = useMediaPending(entity);
47
+ const ask = (kind, item, args) => trackMedia(entity, { kind, item: item.id, label: item.title }, handle.command('playMedia', args));
48
+ const askedOf = (item) => waiting.filter((entry) => entry.item === item.id).map((entry) => entry.kind);
42
49
  /** What the actions do. Playing replaces the queue, as in Spotify: a track in an album or playlist
43
50
  * queues all of it and starts there, so Next goes to the next track of the album. */
44
51
  const play = (item) => {
@@ -46,17 +53,17 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
46
53
  const within = item.kind === 'track' && container && ['album', 'playlist'].includes(container.kind)
47
54
  ? container
48
55
  : undefined;
49
- void handle.command('playMedia', {
56
+ ask('playItem', item, {
50
57
  item: item.id,
51
58
  mode: 'replace',
52
59
  ...(within ? { context: within.id } : {}),
53
60
  });
54
61
  onPlay?.(item);
55
62
  };
56
- const playNext = (item) => void handle.command('playMedia', { item: item.id, mode: 'next' });
57
- const addToQueue = (item) => void handle.command('playMedia', { item: item.id, mode: 'add' });
63
+ const playNext = (item) => ask('queueNext', item, { item: item.id, mode: 'next' });
64
+ const addToQueue = (item) => ask('queueAdd', item, { item: item.id, mode: 'add' });
58
65
  const shuffleAll = (item) => {
59
- void handle.command('playMedia', { item: item.id, mode: 'replace', shuffle: true });
66
+ ask('playItem', item, { item: item.id, mode: 'replace', shuffle: true });
60
67
  onPlay?.(item);
61
68
  };
62
69
  /** Something is open inside the selected shelf (not a search): the tab shows the way back. */
@@ -101,7 +108,7 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
101
108
  ariaLabel: 'Back',
102
109
  }
103
110
  : { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) }) }), actionsHere, searchTrigger] })) : null;
104
- return (_jsxs(Flex, { direction: "column", gap: 3, minHeight: 0, position: "relative", children: [onDemand ? (_jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(motion.div, { initial: { opacity: 0, x: fieldShown ? 28 : 0 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: fieldShown ? 0 : 28 }, transition: { duration: 0.18, ease: 'easeOut' }, children: fieldShown ? search : browser.tabs === undefined ? header : tabsRow }, fieldShown ? 'field' : 'row') })) : (_jsxs(_Fragment, { children: [browser.tabs === undefined ? header : null, search, tabsRow] })), browser.error ? (_jsx(Message, { theater: theater, children: _jsx(Typography, { as: "p", variant: "body", color: "danger", role: "alert", m: 0, children: browser.error }) })) : browser.loading ? (_jsx(BrowseSkeleton, { theater: theater })) : browser.items.length === 0 ? (_jsx(Message, { theater: theater, children: _jsx(Typography, { as: "p", variant: "body", color: "textMuted", m: 0, children: browser.searching ? 'Nothing found.' : 'Nothing here.' }) })) : theater ? (_jsx(FadeScroll, { as: "ul", gap: 4, m: 0, p: 0, pb: 2, css: { ...THEATER_STAGE, listStyle: 'none', scrollSnapType: 'x proximity' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none', scrollSnapAlign: 'start' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseCard, { item: item, queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) })) : (_jsx(Flex, { direction: "column", gap: 1, as: "ul", m: 0, p: 0, minHeight: 0, css: { listStyle: 'none', overflowY: 'auto' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseRow, { item: item, queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onNext: () => playNext(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) }))] }));
111
+ return (_jsxs(Flex, { direction: "column", gap: 3, minHeight: 0, position: "relative", children: [onDemand ? (_jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(motion.div, { initial: { opacity: 0, x: fieldShown ? 28 : 0 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: fieldShown ? 0 : 28 }, transition: { duration: 0.18, ease: 'easeOut' }, children: fieldShown ? search : browser.tabs === undefined ? header : tabsRow }, fieldShown ? 'field' : 'row') })) : (_jsxs(_Fragment, { children: [browser.tabs === undefined ? header : null, search, tabsRow] })), browser.error ? (_jsx(Message, { theater: theater, children: _jsx(Typography, { as: "p", variant: "body", color: "danger", role: "alert", m: 0, children: browser.error }) })) : browser.loading ? (_jsx(BrowseSkeleton, { theater: theater })) : browser.items.length === 0 ? (_jsx(Message, { theater: theater, children: _jsx(Typography, { as: "p", variant: "body", color: "textMuted", m: 0, children: browser.searching ? 'Nothing found.' : 'Nothing here.' }) })) : theater ? (_jsx(FadeScroll, { as: "ul", gap: 4, m: 0, p: 0, pb: 2, css: { ...THEATER_STAGE, listStyle: 'none', scrollSnapType: 'x proximity' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none', scrollSnapAlign: 'start' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseCard, { item: item, asked: askedOf(item), queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) })) : (_jsx(Flex, { direction: "column", gap: 1, as: "ul", m: 0, p: 0, minHeight: 0, css: { listStyle: 'none', overflowY: 'auto' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseRow, { item: item, asked: askedOf(item), queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onNext: () => playNext(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) }))] }));
105
112
  }
106
113
  /** What shows where the list will be while it loads: placeholders shaped like what is coming (cards in
107
114
  * the theater layout, rows in the list), in the room the real thing takes, so nothing moves. */
@@ -118,11 +125,11 @@ function PillButton({ icon, label, onClick, primary, compact, }) {
118
125
  // Built like a tab (a button with the same padding), so the row they share does not change height.
119
126
  return (_jsxs(Flex, { as: "button", type: "button", onClick: onClick, "aria-label": label, title: label, align: "center", justify: "center", gap: 1.5, height: CHIP_HEIGHT, px: 3, radius: "full", cursor: "pointer", background: primary ? 'accent' : 'surfaceRaised', color: primary ? 'accentText' : 'text', css: { flex: 'none', whiteSpace: 'nowrap' }, children: [_jsx(Icon, { name: icon, size: 14 }), compact ? null : (_jsx(Typography, { as: "span", variant: "body", children: label }))] }));
120
127
  }
121
- function BrowseRow({ item, queueable, onOpen, onPlay, onNext, onAdd, }) {
128
+ function BrowseRow({ item, asked, queueable, onOpen, onPlay, onNext, onAdd, }) {
122
129
  // A row opens what can be opened and otherwise plays; an openable thing that can also be played
123
130
  // (an album) gets its own play button.
124
131
  const main = item.expandable ? onOpen : item.playable ? onPlay : undefined;
125
- return (_jsxs(Flex, { align: "center", gap: 2, children: [_jsxs(PlainButton, { align: "center", gap: 3, grow: 1, minWidth: 0, radius: "row", py: 1.5, px: 2, cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, css: ({ palette }) => ({ '&:hover:not(:disabled)': { background: palette.surfaceRaised } }), children: [_jsx(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: "textMuted", radius: "small", width: 44, height: 44, overflow: "hidden", css: { flex: 'none' }, children: item.artworkUrl ? (_jsx(Cover, { src: item.artworkUrl })) : (_jsx(Icon, { name: KIND_ICONS[item.kind], size: 20 })) }), _jsxs(Flex, { direction: "column", minWidth: 0, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", children: _jsx(MarqueeText, { children: item.title }) }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: _jsx(MarqueeText, { children: item.subtitle }) })) : null] })] }), queueable && item.playable ? (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: "lu:list-start", label: `Play ${item.title} next`, glyph: 16, onClick: onNext }), _jsx(IconButton, { icon: "lu:list-plus", label: `Add ${item.title} to the queue`, glyph: 16, onClick: onAdd })] })) : null, item.expandable && item.playable ? (_jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, onClick: onPlay })) : null] }));
132
+ return (_jsxs(Flex, { align: "center", gap: 2, children: [_jsxs(PlainButton, { align: "center", gap: 3, grow: 1, minWidth: 0, radius: "row", py: 1.5, px: 2, cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, css: ({ palette }) => ({ '&:hover:not(:disabled)': { background: palette.surfaceRaised } }), children: [_jsxs(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: "textMuted", radius: "small", width: 44, height: 44, overflow: "hidden", position: "relative", css: { flex: 'none' }, children: [item.artworkUrl ? (_jsx(Cover, { src: item.artworkUrl })) : (_jsx(Icon, { name: KIND_ICONS[item.kind], size: 20 })), asked.includes('playItem') ? _jsx(Waiting, { size: 20 }) : null] }), _jsxs(Flex, { direction: "column", minWidth: 0, grow: 1, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", children: _jsx(MarqueeText, { children: item.title }) }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: _jsx(MarqueeText, { children: item.subtitle }) })) : null] }), item.expandable ? _jsx(Opens, {}) : null] }), queueable && item.playable ? (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: "lu:list-start", label: `Play ${item.title} next`, glyph: 16, disabled: asked.includes('queueNext'), feedback: asked.includes('queueNext') ? 'pending' : undefined, onClick: onNext }), _jsx(IconButton, { icon: "lu:list-plus", label: `Add ${item.title} to the queue`, glyph: 16, disabled: asked.includes('queueAdd'), feedback: asked.includes('queueAdd') ? 'pending' : undefined, onClick: onAdd })] })) : null, item.expandable && item.playable ? (_jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, disabled: asked.includes('playItem'), feedback: asked.includes('playItem') ? 'pending' : undefined, onClick: onPlay })) : null] }));
126
133
  }
127
134
  /** The room the theater row takes, whatever it holds: it asks for full-size cards and gives way when
128
135
  * there is less, and is a size container so the cards can shrink to fit. What stands in for the row
@@ -143,7 +150,26 @@ const CARD_SIZE = `min(${CARD}px, max(${MIN_CARD}px, calc(100cqh - ${LABEL}px)))
143
150
  /** An item as a large card for the theater layout: big artwork (round for an artist), then the
144
151
  * title and subtitle. It opens or plays like a row does, and an album or playlist gets its play
145
152
  * button over the artwork's corner. */
146
- function BrowseCard({ item, queueable, onOpen, onPlay, onAdd, }) {
153
+ function BrowseCard({ item, asked, queueable, onOpen, onPlay, onAdd, }) {
147
154
  const main = item.expandable ? onOpen : item.playable ? onPlay : undefined;
148
- return (_jsxs(Box, { position: "relative", css: { width: CARD_SIZE }, children: [_jsxs(PlainButton, { direction: "column", gap: 2, width: "100%", cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, children: [_jsx(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: "textMuted", radius: item.kind === 'artist' ? 'full' : 'card', overflow: "hidden", css: { width: CARD_SIZE, height: CARD_SIZE }, children: item.artworkUrl ? (_jsx(Cover, { src: item.artworkUrl })) : (_jsx(Icon, { name: KIND_ICONS[item.kind], size: 56 })) }), _jsxs(Flex, { direction: "column", minWidth: 0, px: 1, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", children: _jsx(MarqueeText, { children: item.title }) }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: _jsx(MarqueeText, { children: item.subtitle }) })) : null] })] }), item.playable && (queueable || item.expandable) ? (_jsxs(Flex, { align: "center", gap: 1, position: "absolute", css: { right: 10, top: `calc(${CARD_SIZE} - 54px)` }, children: [queueable ? (_jsx(IconButton, { icon: "lu:list-plus", label: `Add ${item.title} to the queue`, glyph: 16, onClick: onAdd })) : null, item.expandable ? (_jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, onClick: onPlay })) : null] })) : null] }));
155
+ return (_jsxs(Box, { position: "relative", css: { width: CARD_SIZE }, children: [_jsxs(PlainButton, { direction: "column", gap: 2, width: "100%", cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, children: [_jsxs(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: "textMuted", radius: item.kind === 'artist' ? 'full' : 'card', overflow: "hidden", position: "relative", css: { width: CARD_SIZE, height: CARD_SIZE }, children: [item.artworkUrl ? (_jsx(Cover, { src: item.artworkUrl })) : (_jsx(Icon, { name: KIND_ICONS[item.kind], size: 56 })), asked.includes('playItem') ? _jsx(Waiting, { size: 32 }) : null] }), _jsxs(Flex, { direction: "column", minWidth: 0, px: 1, children: [_jsxs(Flex, { align: "center", gap: 1, minWidth: 0, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", css: { minWidth: 0 }, children: _jsx(MarqueeText, { children: item.title }) }), item.expandable ? _jsx(Opens, {}) : null] }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: _jsx(MarqueeText, { children: item.subtitle }) })) : null] })] }), item.playable && (queueable || item.expandable) ? (_jsxs(Flex, { align: "center", gap: 1, position: "absolute", css: { right: 10, top: `calc(${CARD_SIZE} - 54px)` }, children: [queueable ? (_jsx(IconButton, { icon: "lu:list-plus", label: `Add ${item.title} to the queue`, glyph: 16, disabled: asked.includes('queueAdd'), feedback: asked.includes('queueAdd') ? 'pending' : undefined, onClick: onAdd })) : null, item.expandable ? (_jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, disabled: asked.includes('playItem'), feedback: asked.includes('playItem') ? 'pending' : undefined, onClick: onPlay })) : null] })) : null] }));
156
+ }
157
+ /** Over an item's artwork while the player has not answered a request to play it. */
158
+ function Waiting({ size }) {
159
+ return (_jsx(Flex, { align: "center", justify: "center", position: "absolute", css: ({ palette }) => ({
160
+ inset: 0,
161
+ color: palette.text,
162
+ // The artwork is dimmed by a layer of the page's own color, under the spinner.
163
+ '&::before': {
164
+ content: '""',
165
+ position: 'absolute',
166
+ inset: 0,
167
+ background: palette.bg,
168
+ opacity: 0.6,
169
+ },
170
+ }), children: _jsx(Box, { position: "relative", css: { display: 'grid' }, children: _jsx(Spinner, { size: size }) }) }));
171
+ }
172
+ /** Marks something that opens (an album, a playlist, an artist, a folder) and is not played by a tap, as against a track. */
173
+ function Opens() {
174
+ return (_jsx(Box, { color: "textMuted", css: { flex: 'none', display: 'grid' }, "aria-hidden": "true", children: _jsx(Icon, { name: "lu:chevron-right", size: 16 }) }));
149
175
  }
@@ -1,4 +1,6 @@
1
+ import type { EntityRef } from '@hashsome/core';
1
2
  import type { EntityHandle } from '../entity-handle.ts';
3
+ import type { MediaPlayerEntity, QueueResult } from '@hashsome/core';
2
4
  type Handle = EntityHandle<'mediaPlayer'>;
3
5
  /** The volume of a player as a 0..100 level plus the drag in progress: what to draw, and what a
4
6
  * drag, a commit and the mute button each ask the player to do. 0 is mute and anything above it
@@ -11,10 +13,26 @@ export declare function useVolumeControl(handle: Handle): {
11
13
  commit: (next: number) => void;
12
14
  toggleMute: () => void;
13
15
  };
14
- /** A press that changes nothing visible until the player reports back (a skip) confirms itself:
15
- * the button dims while the call is in flight, then flashes, or turns red on failure. */
16
- export declare function usePressFeedback(): {
17
- feedbackFor: (key: string) => "done" | "error" | "pending" | undefined;
16
+ /** A press that changes nothing visible until the player takes it up (play, pause, a skip, shuffle) shows it is
17
+ * waiting: the button is pending (a ring turns around it and it cannot be pressed again) until the player
18
+ * has changed, or turns red when the request fails. The pending part is shared per player, so every control
19
+ * of it agrees. */
20
+ export declare function usePressFeedback(ref: EntityRef | undefined): {
21
+ feedbackFor: (key: string) => 'pending' | 'error' | undefined;
18
22
  press: (key: string, action: () => Promise<void>) => void;
19
23
  };
24
+ /** What the transport buttons can do now, from the queue: Next when something comes after the track playing,
25
+ * Previous when something comes before it (or the track is far enough in to restart), Play when there is
26
+ * something loaded or queued. Without a queue to read (a player with none, or before it answers) nothing
27
+ * is held back: a button is only disabled for what is known. */
28
+ export declare function transportAvailability(queue: QueueResult | undefined, player: MediaPlayerEntity | undefined, position: number | undefined): {
29
+ next: boolean;
30
+ previous: boolean;
31
+ play: boolean;
32
+ };
33
+ export declare function useTransportAvailability(player: MediaPlayerEntity | undefined, position: number | undefined): {
34
+ next: boolean;
35
+ previous: boolean;
36
+ play: boolean;
37
+ };
20
38
  export {};
@@ -1,4 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
+ import { useMediaQueue } from '../use-media-queue.js';
3
+ import { trackMedia, useMediaPending } from './media-pending.js';
2
4
  /** Unmuting while the level is 0 would restore silence, so it jumps to this instead. */
3
5
  const UNMUTE_FROM_ZERO = 0.15;
4
6
  /** The volume of a player as a 0..100 level plus the drag in progress: what to draw, and what a
@@ -32,22 +34,55 @@ export function useVolumeControl(handle) {
32
34
  },
33
35
  };
34
36
  }
35
- /** A press that changes nothing visible until the player reports back (a skip) confirms itself:
36
- * the button dims while the call is in flight, then flashes, or turns red on failure. */
37
- export function usePressFeedback() {
38
- const [feedback, setFeedback] = useState(null);
37
+ /** A press that changes nothing visible until the player takes it up (play, pause, a skip, shuffle) shows it is
38
+ * waiting: the button is pending (a ring turns around it and it cannot be pressed again) until the player
39
+ * has changed, or turns red when the request fails. The pending part is shared per player, so every control
40
+ * of it agrees. */
41
+ export function usePressFeedback(ref) {
42
+ const waiting = useMediaPending(ref);
43
+ const [failed, setFailed] = useState(null);
39
44
  const timer = useRef(undefined);
40
45
  useEffect(() => () => clearTimeout(timer.current), []);
41
46
  return {
42
- feedbackFor: (key) => (feedback?.key === key ? feedback.state : undefined),
47
+ feedbackFor: (key) => waiting.some((entry) => entry.kind === key)
48
+ ? 'pending'
49
+ : failed === key
50
+ ? 'error'
51
+ : undefined,
43
52
  press: (key, action) => {
44
53
  clearTimeout(timer.current);
45
- setFeedback({ key, state: 'pending' });
46
- action()
47
- .then(() => setFeedback({ key, state: 'done' }), () => setFeedback({ key, state: 'error' }))
48
- .finally(() => {
49
- timer.current = setTimeout(() => setFeedback(null), 700);
54
+ setFailed(null);
55
+ const request = action();
56
+ if (ref) {
57
+ trackMedia(ref, { kind: key }, request);
58
+ }
59
+ request.catch(() => {
60
+ setFailed(key);
61
+ timer.current = setTimeout(() => setFailed(null), 700);
50
62
  });
51
63
  },
52
64
  };
53
65
  }
66
+ /** Past this far into a track, Previous restarts it, so it has somewhere to go even on the first one. */
67
+ const RESTART_AFTER_S = 3;
68
+ /** What the transport buttons can do now, from the queue: Next when something comes after the track playing,
69
+ * Previous when something comes before it (or the track is far enough in to restart), Play when there is
70
+ * something loaded or queued. Without a queue to read (a player with none, or before it answers) nothing
71
+ * is held back: a button is only disabled for what is known. */
72
+ export function transportAvailability(queue, player, position) {
73
+ const current = queue ? queue.items.findIndex((item) => item.current === true) : -1;
74
+ const known = queue !== undefined && current >= 0;
75
+ const index = known ? queue.offset + current : 0;
76
+ return {
77
+ next: !known || index + 1 < queue.total,
78
+ previous: !known || index > 0 || (position ?? 0) >= RESTART_AFTER_S,
79
+ play: queue === undefined ||
80
+ queue.total > 0 ||
81
+ player?.media?.title !== undefined ||
82
+ player?.playback === 'playing',
83
+ };
84
+ }
85
+ export function useTransportAvailability(player, position) {
86
+ const { queue } = useMediaQueue(player?.ref);
87
+ return transportAvailability(queue, player, position);
88
+ }
@@ -0,0 +1,9 @@
1
+ import type { EntityRef } from '@hashsome/core';
2
+ /** What a player has been asked to start and is not ready with yet, as the line a player shows in the place
3
+ * of the artist: a spinner and "Loading <title>…". Nothing, when it has been asked nothing. Starting a
4
+ * playlist can take many seconds, during which the player would otherwise look as if nothing was happening. */
5
+ export declare function useLoadingLabel(ref: EntityRef | undefined): string | undefined;
6
+ export declare function LoadingLine({ label, variant }: {
7
+ label: string;
8
+ variant: 'body' | 'secondary';
9
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { Flex, Typography } from 'e-prim';
4
+ import { Spinner } from '../layout/spinner.js';
5
+ import { useMediaPending } from './media-pending.js';
6
+ /** What a player has been asked to start and is not ready with yet, as the line a player shows in the place
7
+ * of the artist: a spinner and "Loading <title>…". Nothing, when it has been asked nothing. Starting a
8
+ * playlist can take many seconds, during which the player would otherwise look as if nothing was happening. */
9
+ export function useLoadingLabel(ref) {
10
+ return useMediaPending(ref).find((entry) => entry.kind === 'playItem')?.label;
11
+ }
12
+ export function LoadingLine({ label, variant }) {
13
+ return (_jsxs(Flex, { align: "center", gap: 1.5, minWidth: 0, color: "accent", css: { minWidth: 0 }, children: [_jsx(Spinner, { size: variant === 'body' ? 14 : 12, label: "Loading" }), _jsxs(Typography, { as: "span", variant: variant, noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: ["Loading ", label, "\u2026"] })] }));
14
+ }
@@ -0,0 +1,24 @@
1
+ import type { EntityRef } from '@hashsome/core';
2
+ /** Something asked of a player that has not taken effect yet. Starting an album or a playlist can take
3
+ * the player many seconds after it has acknowledged the request, a skip a few, and nothing on screen
4
+ * changes until it does: so the button, the item and the player say it is waiting. */
5
+ export interface MediaPending {
6
+ /** What was asked. A transport command is its own name (`togglePlay`, `next`, `previous`, `setShuffle`);
7
+ * from the library: `playItem` (replaces what is playing, so the player says it is loading it), `queueNext`, `queueAdd`. */
8
+ kind: string;
9
+ /** For the library kinds: the item it was asked for. */
10
+ item?: string;
11
+ /** What to call it while it loads: the item's title. */
12
+ label?: string;
13
+ }
14
+ /** The most a request is shown as pending: a backend that never answers must not leave a ring for good. */
15
+ export declare const PENDING_LIMIT_MS = 60000;
16
+ /** Shows `entry` as pending for `ref` from now until the player has taken it up, whichever way: until it
17
+ * refuses it, or changes after acknowledging it, or a while after (`useMediaPending` reports the change). */
18
+ export declare function trackMedia(ref: string, entry: MediaPending, request: Promise<unknown>): void;
19
+ /** Forgets everything pending: for tests, which share this module between them. */
20
+ export declare function resetMediaPending(): void;
21
+ /** What is being asked of a player and has not taken effect yet: for a ring on the button, a spinner on
22
+ * the item, and a "loading" line on the player. Also watches the player for the change that ends the
23
+ * wait: a different track, play or pause, shuffle. */
24
+ export declare function useMediaPending(ref: EntityRef | undefined): MediaPending[];
@@ -0,0 +1,121 @@
1
+ import { useEffect, useSyncExternalStore } from 'react';
2
+ import { useEntity } from '../hooks.js';
3
+ /** Asked and not in effect yet, for each player by ref. A new array each time it changes, which is
4
+ * what `useSyncExternalStore` compares. */
5
+ const pending = new Map();
6
+ const view = new Map();
7
+ const listeners = new Set();
8
+ const NONE = [];
9
+ /** The most a request is shown as pending: a backend that never answers must not leave a ring for good. */
10
+ export const PENDING_LIMIT_MS = 60_000;
11
+ const lastSeen = new Map();
12
+ /** Asks that change the track are done when another track plays, and not when it is only announced. */
13
+ const changesTrack = (kind) => kind === 'next' || kind === 'previous' || kind === 'playItem';
14
+ /** How long after the player acknowledges a request to wait for it to take effect, when it never
15
+ * visibly does (a skip back on a track already at its start). A skip or a start from the library
16
+ * takes the player several seconds. */
17
+ const settleFor = (kind) => kind === 'playItem' ? 15_000 : kind === 'next' ? 10_000 : kind === 'previous' ? 4_000 : 2_000;
18
+ /** A position past this many seconds means the track plays, and is not only announced. */
19
+ const STARTED_AFTER_S = 0.05;
20
+ /** At least this long is shown for a request, so a quick answer still shows a turn. */
21
+ const MIN_SHOWN_MS = 600;
22
+ const publish = (ref) => {
23
+ const list = pending.get(ref) ?? [];
24
+ if (list.length > 0) {
25
+ view.set(ref, list.map((waiting) => waiting.entry));
26
+ }
27
+ else {
28
+ view.delete(ref);
29
+ }
30
+ for (const listener of listeners) {
31
+ listener();
32
+ }
33
+ };
34
+ /** Shows `entry` as pending for `ref` from now until the player has taken it up, whichever way: until it
35
+ * refuses it, or changes after acknowledging it, or a while after (`useMediaPending` reports the change). */
36
+ export function trackMedia(ref, entry, request) {
37
+ const waiting = { entry, answered: false, baseTitle: lastSeen.get(ref)?.title };
38
+ const started = Date.now();
39
+ pending.set(ref, [...(pending.get(ref) ?? []), waiting]);
40
+ publish(ref);
41
+ const timers = new Set();
42
+ const finish = () => {
43
+ for (const timer of timers) {
44
+ clearTimeout(timer);
45
+ }
46
+ const rest = (pending.get(ref) ?? []).filter((other) => other !== waiting);
47
+ if (rest.length > 0) {
48
+ pending.set(ref, rest);
49
+ }
50
+ else {
51
+ pending.delete(ref);
52
+ }
53
+ publish(ref);
54
+ };
55
+ timers.add(setTimeout(finish, PENDING_LIMIT_MS));
56
+ request.then(() => {
57
+ const shown = Date.now() - started;
58
+ const settle = settleFor(entry.kind);
59
+ waiting.answered = true;
60
+ const taken = () => {
61
+ // The player changed: done, once it has been shown for long enough.
62
+ timers.add(setTimeout(finish, Math.max(0, MIN_SHOWN_MS - (Date.now() - started))));
63
+ };
64
+ waiting.release = taken;
65
+ timers.add(setTimeout(finish, Math.max(settle, MIN_SHOWN_MS - shown)));
66
+ if (waiting.changed) {
67
+ taken();
68
+ }
69
+ }, finish);
70
+ }
71
+ /** The player changed: what it has acknowledged is now in effect, once it is as far as each ask needs. */
72
+ function observe(ref, seen) {
73
+ lastSeen.set(ref, seen);
74
+ for (const waiting of pending.get(ref) ?? []) {
75
+ const done = !changesTrack(waiting.entry.kind) || (seen.title !== waiting.baseTitle && seen.started);
76
+ if (!done) {
77
+ continue;
78
+ }
79
+ if (waiting.answered) {
80
+ waiting.release?.();
81
+ waiting.release = undefined;
82
+ }
83
+ else {
84
+ waiting.changed = true;
85
+ }
86
+ }
87
+ }
88
+ /** Forgets everything pending: for tests, which share this module between them. */
89
+ export function resetMediaPending() {
90
+ pending.clear();
91
+ view.clear();
92
+ lastSeen.clear();
93
+ }
94
+ const subscribe = (listener) => {
95
+ listeners.add(listener);
96
+ return () => {
97
+ listeners.delete(listener);
98
+ };
99
+ };
100
+ /** What is being asked of a player and has not taken effect yet: for a ring on the button, a spinner on
101
+ * the item, and a "loading" line on the player. Also watches the player for the change that ends the
102
+ * wait: a different track, play or pause, shuffle. */
103
+ export function useMediaPending(ref) {
104
+ const player = useEntity(ref);
105
+ const media = player?.kind === 'mediaPlayer' ? player : undefined;
106
+ const present = media !== undefined;
107
+ const title = media?.media?.title;
108
+ const started = media?.playback === 'playing' && (media.position ?? 0) >= STARTED_AFTER_S;
109
+ // Anything else the player does (play or pause, shuffle, another album) ends what was asked of it too.
110
+ const playback = media?.playback;
111
+ const album = media?.media?.album;
112
+ const shuffle = media?.shuffle;
113
+ useEffect(() => {
114
+ if (ref && present) {
115
+ observe(ref, { title, started });
116
+ }
117
+ // `playback`, `album` and `shuffle` are not read inside: a change to any of them is the reason to look again.
118
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
119
+ }, [ref, present, title, started, playback, album, shuffle]);
120
+ return useSyncExternalStore(subscribe, () => (ref ? (view.get(ref) ?? NONE) : NONE), () => NONE);
121
+ }
@@ -11,13 +11,15 @@ import { HoldProgress } from '../layout/hold-progress.js';
11
11
  import { useHold } from '../layout/use-hold.js';
12
12
  import { IconButton } from '../layout/tile.js';
13
13
  import { statusLabels } from '../status.js';
14
- import { usePressFeedback, useVolumeControl } from './media-controls.js';
14
+ import { usePressFeedback, useTransportAvailability, useVolumeControl } from './media-controls.js';
15
15
  import { Cover } from '../layout/cover.js';
16
+ import { Spinner } from '../layout/spinner.js';
16
17
  import { PlainButton } from '../layout/plain-button.js';
17
18
  import { MediaBrowser } from './media-browser.js';
19
+ import { LoadingLine, useLoadingLabel } from './media-loading-line.js';
18
20
  import { MediaPlayerFull } from './media-player-full.js';
19
21
  import { MediaQueue } from './media-queue.js';
20
- import { formatDuration, useMediaPosition } from './media-progress.js';
22
+ import { formatDuration, useMediaPosition, useSteadyPlaying } from './media-progress.js';
21
23
  import { useSeekHold } from './media-seek.js';
22
24
  import { SeekLine } from './seek-line.js';
23
25
  /** Narrower than this, a bar with `rows="auto"` is two rows. */
@@ -137,14 +139,16 @@ export function MediaPlayerBar({ entity, name, browse, drawer = true, onOpen, ov
137
139
  const player = handle.entity;
138
140
  const status = handle.status;
139
141
  const [volumeOpen, setVolumeOpen] = useState(false);
142
+ const loading = useLoadingLabel(player?.ref);
140
143
  const ready = status === 'ready';
141
- const playing = player?.playback === 'playing';
144
+ const playing = useSteadyPlaying(player);
142
145
  const muted = player?.muted === true;
143
146
  const caps = player?.capabilities;
144
- const { feedbackFor, press } = usePressFeedback();
147
+ const { feedbackFor, press } = usePressFeedback(player?.ref);
145
148
  const volume = useVolumeControl(handle);
146
149
  const artwork = (_jsx(Flex, { as: "span", center: true, width: 30, height: 30, radius: "full", overflow: "hidden", background: "surfaceRaised", color: "textMuted", children: player?.media?.artworkUrl ? (_jsx(Cover, { src: player.media.artworkUrl })) : (_jsx(Icon, { name: "lu:music", size: 14 })) }));
147
150
  const position = useMediaPosition(player);
151
+ const can = useTransportAvailability(player, position);
148
152
  const seek = useSeekHold(ready ? position : undefined, caps?.seek ? (next) => void handle.command('seek', { position: next }) : undefined);
149
153
  const duration = ready ? player?.duration : undefined;
150
154
  const timed = seek.shown !== undefined && duration !== undefined && duration > 0;
@@ -164,10 +168,10 @@ export function MediaPlayerBar({ entity, name, browse, drawer = true, onOpen, ov
164
168
  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
169
  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
170
  ? (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] }) }));
171
+ : statusLabels[status] }), ready && caps?.shuffle ? (_jsx(PlainButton, { "aria-label": "Shuffle", "aria-pressed": player?.shuffle === true, title: "Shuffle", onClick: () => press('setShuffle', () => handle.command('setShuffle', { shuffle: player?.shuffle !== true })), center: true, color: player?.shuffle === true ? 'accent' : 'textMuted', css: { flex: 'none' }, children: feedbackFor('setShuffle') === 'pending' ? (_jsx(Spinner, { size: 14 })) : (_jsx(Icon, { name: "lu:shuffle", size: 14 })) })) : null] }), loading ? (_jsx(LoadingLine, { label: loading, variant: "secondary" })) : ready && player?.media?.artist ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", children: player.media.artist })) : null] }) }));
168
172
  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
173
  ? 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] }));
174
+ : 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 || !can.previous, 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 || !can.play, primary: true, feedback: feedbackFor('togglePlay'), onClick: () => press('togglePlay', () => handle.command('togglePlay')) }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: MEDIA_GLYPH, disabled: !ready || caps?.next === false || !can.next, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) })] }), timed ? (_jsx(SeekLine, { seek: seek, duration: duration ?? 0, seekable: caps?.seek === true })) : null] }));
171
175
  // Holding the card, the artwork and the browse button open the same drawer: the player laid out
172
176
  // like the vertical one, with the library below it.
173
177
  if (!ready) {
@@ -5,3 +5,7 @@ export declare function formatDuration(seconds: number): string;
5
5
  * moves it forward from there on its own clock, so the bar advances without a message per second.
6
6
  * `undefined` until the player reports a position. */
7
7
  export declare function useMediaPosition(player: MediaPlayerEntity | undefined): number | undefined;
8
+ /** Whether to show the player as playing: `playback === 'playing'`, except that a player which flips to
9
+ * idle and back within a moment as a new track starts (as Music Assistant's do) does not make the button
10
+ * change to Play and back. A pause at any other time shows at once. */
11
+ export declare function useSteadyPlaying(player: MediaPlayerEntity | undefined): boolean;
@@ -28,3 +28,44 @@ export function useMediaPosition(player) {
28
28
  const position = player.position + elapsed;
29
29
  return player.duration === undefined ? position : Math.min(player.duration, position);
30
30
  }
31
+ /** How long after a track changes the player may dip out of playing without the button showing it. */
32
+ const NEW_TRACK_MS = 6000;
33
+ /** How long such a dip has to last to be believed. */
34
+ const DIP_MS = 800;
35
+ /** Whether to show the player as playing: `playback === 'playing'`, except that a player which flips to
36
+ * idle and back within a moment as a new track starts (as Music Assistant's do) does not make the button
37
+ * change to Play and back. A pause at any other time shows at once. */
38
+ export function useSteadyPlaying(player) {
39
+ const raw = player?.playback === 'playing';
40
+ const track = player?.media?.title;
41
+ // A track change opens a short window, in which a dip out of playing is held up for a moment.
42
+ const [seen, setSeen] = useState(track);
43
+ const [recent, setRecent] = useState(false);
44
+ if (seen !== track) {
45
+ setSeen(track);
46
+ setRecent(true);
47
+ }
48
+ useEffect(() => {
49
+ if (!recent) {
50
+ return;
51
+ }
52
+ const id = setTimeout(() => setRecent(false), NEW_TRACK_MS);
53
+ return () => clearTimeout(id);
54
+ // `track` is not read inside: another change of track restarts the window.
55
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
56
+ }, [recent, track]);
57
+ const [was, setWas] = useState(raw);
58
+ const [held, setHeld] = useState(false);
59
+ if (raw !== was) {
60
+ setWas(raw);
61
+ setHeld(!raw && recent);
62
+ }
63
+ useEffect(() => {
64
+ if (!held) {
65
+ return;
66
+ }
67
+ const id = setTimeout(() => setHeld(false), DIP_MS);
68
+ return () => clearTimeout(id);
69
+ }, [held]);
70
+ return raw || held;
71
+ }
@@ -4,6 +4,7 @@ import { Flex } from 'e-prim';
4
4
  import { NavLink } from 'react-router';
5
5
  import { Icon } from '../icon.js';
6
6
  import { usePageInset } from '../layout/page.js';
7
+ import { AttentionDot } from './attention-dot.js';
7
8
  import { useThemeToggle } from '../provider.js';
8
9
  /** Floating bottom pill for switching between a dashboard's pages. Must be rendered inside a
9
10
  * router. `position: fixed`, floating over the page; it reserves the space it takes in the
@@ -14,7 +15,7 @@ export function NavDock({ items, base, showThemeToggle }) {
14
15
  bottom: spacing(5),
15
16
  left: '50%',
16
17
  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: "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
+ }), children: [items.map((item) => (_jsx(NavLink, { to: item.to ? `${base}/${item.to}` : base, end: true, "aria-label": item.attention ? `${item.label}, needs attention` : item.label, title: item.label, children: ({ isActive }) => (_jsxs(Flex, { as: "span", align: "center", justify: "center", width: 44, height: 44, radius: "full", cursor: "pointer", position: "relative", color: isActive ? 'accentText' : 'line', ...(isActive ? { background: 'accent' } : {}), children: [_jsx(Icon, { name: item.icon, size: 22 }), item.attention ? _jsx(AttentionDot, { level: item.attention }) : null] })) }, item.to))), showThemeToggle ? _jsx(ThemeToggleButton, {}) : null] }));
18
19
  }
19
20
  function ThemeToggleButton() {
20
21
  const { resolved, toggle } = useThemeToggle();
@@ -4,6 +4,8 @@ export interface NavItem {
4
4
  to: string;
5
5
  label: string;
6
6
  icon: IconName;
7
+ /** A dot on the item for something on that page that needs a look: `notice` (warm) or `urgent` (red). Nothing by default. */
8
+ attention?: 'notice' | 'urgent';
7
9
  }
8
10
  export interface NavRailProps {
9
11
  /** The pages to link to; `to` is relative to `base`. */
@@ -5,12 +5,14 @@ import { NavLink } from 'react-router';
5
5
  import { Icon } from '../icon.js';
6
6
  import { usePageInset } from '../layout/page.js';
7
7
  import { useThemeToggle } from '../provider.js';
8
+ import { NAV_RAIL } from '../theme/grid.js';
9
+ import { AttentionDot } from './attention-dot.js';
8
10
  /** Fixed left sidebar for switching between a dashboard's pages. Must be rendered inside a
9
11
  * router. It is `position: fixed` and reserves its width in the surrounding `Page`, which pads
10
12
  * for it so content never sits under it. */
11
13
  export function NavRail({ items, base, showThemeToggle }) {
12
- usePageInset('left', 76);
13
- return (_jsxs(Flex, { as: "nav", "aria-label": "Pages", direction: "column", align: "center", gap: 2.5, background: "rail", width: 76, py: 6, position: "fixed", zIndex: "nav", css: { top: 0, bottom: 0, left: 0 }, 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] }));
14
+ usePageInset('left', NAV_RAIL);
15
+ return (_jsxs(Flex, { as: "nav", "aria-label": "Pages", direction: "column", align: "center", gap: 2.5, background: "rail", width: NAV_RAIL, py: 6, position: "fixed", zIndex: "nav", css: { top: 0, bottom: 0, left: 0 }, children: [items.map((item) => (_jsx(NavLink, { to: item.to ? `${base}/${item.to}` : base, end: true, "aria-label": item.attention ? `${item.label}, needs attention` : item.label, title: item.label, children: ({ isActive }) => (_jsxs(Flex, { as: "span", align: "center", justify: "center", width: 44, height: 44, radius: "chrome", cursor: "pointer", position: "relative", color: isActive ? 'accentText' : 'line', ...(isActive ? { background: 'accent' } : {}), children: [_jsx(Icon, { name: item.icon, size: 22 }), item.attention ? _jsx(AttentionDot, { level: item.attention }) : null] })) }, item.to))), showThemeToggle ? _jsx(ThemeToggleButton, {}) : null] }));
14
16
  }
15
17
  function ThemeToggleButton() {
16
18
  const { resolved, toggle } = useThemeToggle();