@hashsome/ui 0.2.0 → 0.4.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 (36) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/entities/artwork-ring.js +39 -13
  3. package/dist/entities/media-browser.js +69 -25
  4. package/dist/entities/media-player-bar.js +2 -1
  5. package/dist/entities/media-player-column.js +2 -1
  6. package/dist/entities/media-player-full.d.ts +3 -1
  7. package/dist/entities/media-player-full.js +2 -2
  8. package/dist/entities/media-queue.d.ts +14 -0
  9. package/dist/entities/media-queue.js +151 -0
  10. package/dist/entities/top-bar.js +8 -2
  11. package/dist/entities/weather-forecast.js +3 -16
  12. package/dist/gallery/fixtures.d.ts +8 -2
  13. package/dist/gallery/fixtures.js +15 -2
  14. package/dist/gallery/gallery.js +2 -1
  15. package/dist/gallery/props-data.js +17 -0
  16. package/dist/hooks.d.ts +6 -0
  17. package/dist/hooks.js +18 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/layout/drawer-controls.d.ts +2 -0
  21. package/dist/layout/drawer-controls.js +11 -1
  22. package/dist/layout/marquee-text.d.ts +11 -0
  23. package/dist/layout/marquee-text.js +67 -0
  24. package/dist/layout/reveal.d.ts +11 -0
  25. package/dist/layout/reveal.js +19 -0
  26. package/dist/layout/series-chart.js +7 -2
  27. package/dist/layout/skeleton.d.ts +11 -0
  28. package/dist/layout/skeleton.js +16 -0
  29. package/dist/use-entity-history.js +5 -1
  30. package/dist/use-entity-logbook.js +5 -1
  31. package/dist/use-media-browser.d.ts +4 -0
  32. package/dist/use-media-browser.js +11 -3
  33. package/dist/use-media-queue.d.ts +15 -0
  34. package/dist/use-media-queue.js +44 -0
  35. package/dist/use-weather-forecast.js +5 -1
  36. package/package.json +2 -2
@@ -12,20 +12,44 @@ const DEFAULT_SIZE = 168;
12
12
  * `MediaPlayerFull` draw it. */
13
13
  export function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size: SIZE = DEFAULT_SIZE, }) {
14
14
  const ring = useRef(null);
15
- const dragging = useRef(false);
15
+ /** A drag in progress: where round the ring the pointer was last, and how far along it has taken
16
+ * the position, which may run past either end while the pointer carries on (see `follow`). */
17
+ const drag = useRef(null);
16
18
  const total = seekable && duration !== undefined && duration > 0 ? duration : undefined;
17
19
  const shown = seek.shown;
18
20
  const fraction = shown !== undefined && duration ? Math.min(1, Math.max(0, shown / duration)) : undefined;
19
- /** Where on the ring a pointer is, as a position in the item; `undefined` off the ring. */
20
- const positionAt = (event) => {
21
+ /** How far round the ring a pointer is, from the top, clockwise: 0 to 1. */
22
+ const turnAt = (event) => {
21
23
  const box = ring.current?.getBoundingClientRect();
22
- if (!box || !duration) {
24
+ if (!box) {
23
25
  return undefined;
24
26
  }
25
27
  const dx = event.clientX - (box.left + box.width / 2);
26
28
  const dy = event.clientY - (box.top + box.height / 2);
27
- const turn = Math.atan2(dx, -dy) / (2 * Math.PI);
28
- return Math.round(((turn + 1) % 1) * duration);
29
+ return (Math.atan2(dx, -dy) / (2 * Math.PI) + 1) % 1;
30
+ };
31
+ /** Moves a drag with the pointer and says where in the item that is. The position follows how far the
32
+ * pointer has gone round, not which side of the ring it is on, and stops at the start and the end:
33
+ * carrying on past the top (the start and the end meet there) holds it where it is, instead of
34
+ * jumping to the other end, and it only moves again once the pointer is back round. */
35
+ const follow = (event) => {
36
+ const now = turnAt(event);
37
+ const held = drag.current;
38
+ if (now === undefined || !held || !duration) {
39
+ return undefined;
40
+ }
41
+ // The way round that is the short one, so crossing the top does not read as a whole turn.
42
+ let step = now - held.turn;
43
+ if (step > 0.5) {
44
+ step -= 1;
45
+ }
46
+ else if (step < -0.5) {
47
+ step += 1;
48
+ }
49
+ // Only a little past either end is remembered, so the pointer is never far from its place.
50
+ held.along = Math.min(1.25, Math.max(-0.25, held.along + step));
51
+ held.turn = now;
52
+ return Math.round(Math.min(1, Math.max(0, held.along)) * duration);
29
53
  };
30
54
  const onKeyDown = (event) => {
31
55
  const step = event.key === 'ArrowRight' || event.key === 'ArrowUp' ? 5 : -5;
@@ -52,19 +76,21 @@ export function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size
52
76
  if (distance < box.width * 0.4) {
53
77
  return;
54
78
  }
55
- dragging.current = true;
79
+ // Where it was pressed is where the position starts.
80
+ const turn = turnAt(event) ?? 0;
81
+ drag.current = { turn, along: turn };
56
82
  event.currentTarget.setPointerCapture?.(event.pointerId);
57
- seek.preview(positionAt(event) ?? 0);
83
+ seek.preview(Math.round(turn * total));
58
84
  },
59
85
  onPointerMove: (event) => {
60
- if (dragging.current) {
61
- seek.preview(positionAt(event) ?? 0);
86
+ if (drag.current) {
87
+ seek.preview(follow(event) ?? 0);
62
88
  }
63
89
  },
64
90
  onPointerUp: (event) => {
65
- if (dragging.current) {
66
- dragging.current = false;
67
- const next = positionAt(event);
91
+ if (drag.current) {
92
+ const next = follow(event);
93
+ drag.current = null;
68
94
  if (next !== undefined) {
69
95
  seek.commit(next);
70
96
  }
@@ -1,12 +1,15 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
2
  import { Box, Flex, Typography } from 'e-prim';
3
3
  import { Icon } from '../icon.js';
4
4
  import { useEntityHandle } from '../hooks.js';
5
5
  import { useDetail } from '../layout/detail-provider.js';
6
6
  import { Cover } from '../layout/cover.js';
7
- import { ChipRow } from '../layout/drawer-controls.js';
7
+ import { CHIP_HEIGHT, ChipRow } from '../layout/drawer-controls.js';
8
8
  import { FadeScroll } from '../layout/fade-scroll.js';
9
+ import { MarqueeText } from '../layout/marquee-text.js';
9
10
  import { PlainButton } from '../layout/plain-button.js';
11
+ import { Reveal } from '../layout/reveal.js';
12
+ import { Skeleton } from '../layout/skeleton.js';
10
13
  import { IconButton } from '../layout/tile.js';
11
14
  import { useMediaBrowser } from '../use-media-browser.js';
12
15
  const KIND_ICONS = {
@@ -28,40 +31,81 @@ export function MediaBrowser({ entity, onPlay, layout = 'list' }) {
28
31
  const theater = layout === 'theater' || (layout === 'auto' && detail?.expanded === true);
29
32
  const browser = useMediaBrowser(entity);
30
33
  const player = handle.entity;
34
+ // A player with a queue can be told to add to it, play next and shuffle; one without can only play.
35
+ const queueable = player?.capabilities.queue === true;
36
+ /** What the actions do. Playing replaces the queue, as in Spotify: a track in an album or playlist
37
+ * queues all of it and starts there, so Next goes to the next track of the album. */
31
38
  const play = (item) => {
32
- void handle.command('playMedia', { item: item.id });
39
+ const container = browser.inside;
40
+ const within = item.kind === 'track' && container && ['album', 'playlist'].includes(container.kind)
41
+ ? container
42
+ : undefined;
43
+ void handle.command('playMedia', {
44
+ item: item.id,
45
+ mode: 'replace',
46
+ ...(within ? { context: within.id } : {}),
47
+ });
48
+ onPlay?.(item);
49
+ };
50
+ const playNext = (item) => void handle.command('playMedia', { item: item.id, mode: 'next' });
51
+ const addToQueue = (item) => void handle.command('playMedia', { item: item.id, mode: 'add' });
52
+ const shuffleAll = (item) => {
53
+ void handle.command('playMedia', { item: item.id, mode: 'replace', shuffle: true });
33
54
  onPlay?.(item);
34
55
  };
35
56
  /** Something is open inside the selected shelf (not a search): the tab shows the way back. */
36
57
  const inside = browser.canGoBack && !browser.searching;
37
- const header = (_jsxs(Flex, { align: "center", gap: 2, css: { flexShrink: 0 }, children: [browser.canGoBack ? (_jsx(IconButton, { icon: "lu:arrow-left", label: "Back", glyph: 16, onClick: browser.back })) : null, _jsx(Typography, { as: "h2", variant: "heading", grow: 1, m: 0, children: browser.title ?? 'Library' })] }));
58
+ /** What can be done with the whole album, playlist or artist that is open: play it, shuffle it,
59
+ * add it to the queue. Playing it replaces the queue. They sit at the end of the row of tabs (or
60
+ * of the heading), never in a row of their own, so opening an album moves nothing; where there is
61
+ * no room for words they are just their icons. */
62
+ const opened = browser.inside;
63
+ const actionsHere = opened?.playable === true ? (_jsxs(Flex, { align: "center", gap: 2, css: { flexShrink: 0 }, children: [_jsx(PillButton, { icon: "lu:play", label: "Play", compact: !theater, onClick: () => play(opened), primary: true }), queueable ? (_jsxs(_Fragment, { children: [_jsx(PillButton, { icon: "lu:shuffle", label: "Shuffle", compact: !theater, onClick: () => shuffleAll(opened) }), _jsx(PillButton, { icon: "lu:list-plus", label: "Add to queue", compact: !theater, onClick: () => addToQueue(opened) })] })) : null] })) : null;
64
+ const header = (_jsxs(Flex, { align: "center", gap: 2, css: { flexShrink: 0 }, children: [browser.canGoBack ? (_jsx(IconButton, { icon: "lu:arrow-left", label: "Back", glyph: 16, onClick: browser.back })) : null, _jsx(Typography, { as: "h2", variant: "heading", grow: 1, m: 0, children: browser.title ?? 'Library' }), actionsHere] }));
38
65
  const search = player?.capabilities.search ? (_jsxs(Flex, { align: "center", gap: 2, background: "surfaceRaised", radius: "full", px: 3, height: 36, color: "textMuted",
39
66
  // Fixed height: when the list takes all the room, the search keeps its size and the list scrolls.
40
67
  css: { flexShrink: 0 }, children: [_jsx(Icon, { name: "lu:search", size: 16 }), _jsx(Box, { as: "input", type: "search", "aria-label": "Search the library", placeholder: "Search", typography: "body", color: "text", grow: 1, minWidth: 0, value: browser.query, onChange: (event) => browser.search(event.target.value), css: { background: 'none', outline: 'none' } }), browser.query !== '' ? (_jsx(PlainButton, { "aria-label": "Clear search", title: "Clear search", onClick: () => browser.search(''), center: true, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : null] })) : null;
41
- return (_jsxs(Flex, { direction: "column", gap: 3, minHeight: 0, children: [browser.tabs === undefined ? header : null, search, browser.tabs ? (_jsx(ChipRow, { tabs: true, options: browser.tabs.map((item) =>
42
- // Inside a shelf the selected tab becomes the way back, so nothing is added to the
43
- // layout and nothing below it moves.
44
- item.id === browser.activeTab && inside
45
- ? {
46
- value: item.id,
47
- label: browser.title ?? item.title,
48
- icon: 'lu:arrow-left',
49
- ariaLabel: 'Back',
50
- }
51
- : { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) })) : null, browser.error ? (_jsx(Typography, { as: "p", variant: "body", color: "danger", role: "alert", children: browser.error })) : browser.loading ? (_jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "Loading\u2026" })) : browser.items.length === 0 ? (_jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: browser.searching ? 'Nothing found.' : 'Nothing here.' })) : theater ? (_jsx(FadeScroll, { as: "ul", gap: 4, m: 0, p: 0, pb: 2, css: {
52
- listStyle: 'none',
53
- scrollSnapType: 'x proximity',
54
- containerType: 'size',
55
- flex: `0 1 ${CARD + LABEL + 16}px`,
56
- minHeight: MIN_CARD + LABEL + 16,
57
- }, children: browser.items.map((item) => (_jsx("li", { css: { flex: 'none', scrollSnapAlign: 'start' }, children: _jsx(BrowseCard, { item: item, onOpen: () => browser.open(item), onPlay: () => play(item) }) }, 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) => (_jsx("li", { css: { flex: 'none' }, children: _jsx(BrowseRow, { item: item, onOpen: () => browser.open(item), onPlay: () => play(item) }) }, item.id))) }))] }));
68
+ return (_jsxs(Flex, { direction: "column", gap: 3, minHeight: 0, children: [browser.tabs === undefined ? header : null, search, browser.tabs ? (_jsxs(Flex, { align: "center", gap: 3, children: [_jsx(Flex, { direction: "column", grow: 1, minWidth: 0, children: _jsx(ChipRow, { tabs: true, options: browser.tabs.map((item) =>
69
+ // Inside a shelf the selected tab becomes the way back, so nothing is added to the
70
+ // layout and nothing below it moves.
71
+ item.id === browser.activeTab && inside
72
+ ? {
73
+ value: item.id,
74
+ label: browser.title ?? item.title,
75
+ icon: 'lu:arrow-left',
76
+ ariaLabel: 'Back',
77
+ }
78
+ : { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) }) }), actionsHere] })) : null, 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}`))) }))] }));
79
+ }
80
+ /** What shows where the list will be while it loads: placeholders shaped like what is coming (cards in
81
+ * the theater layout, rows in the list), in the room the real thing takes, so nothing moves. */
82
+ function BrowseSkeleton({ theater }) {
83
+ return theater ? (_jsx(Flex, { as: "ul", role: "status", "aria-label": "Loading", "aria-busy": "true", gap: 4, m: 0, p: 0, pb: 2, css: { ...THEATER_STAGE, listStyle: 'none', overflow: 'hidden' }, children: Array.from({ length: 12 }, (_, index) => (_jsxs(Flex, { as: "li", direction: "column", gap: 2, css: { flex: 'none', width: CARD_SIZE }, children: [_jsx(Skeleton, { width: CARD_SIZE, height: CARD_SIZE, radius: "card" }), _jsxs(Flex, { direction: "column", gap: 1, px: 1, children: [_jsx(Skeleton, { width: "70%", height: 14 }), _jsx(Skeleton, { width: "45%", height: 12 })] })] }, index))) })) : (_jsx(Flex, { as: "ul", role: "status", "aria-label": "Loading", "aria-busy": "true", direction: "column", gap: 1, m: 0, p: 0, css: { listStyle: 'none' }, children: Array.from({ length: 6 }, (_, index) => (_jsxs(Flex, { as: "li", align: "center", gap: 3, py: 1.5, px: 2, children: [_jsx(Skeleton, { width: 44, height: 44 }), _jsxs(Flex, { direction: "column", gap: 1.5, grow: 1, children: [_jsx(Skeleton, { width: "60%", height: 14 }), _jsx(Skeleton, { width: "35%", height: 12 })] })] }, index))) }));
58
84
  }
59
- function BrowseRow({ item, onOpen, onPlay, }) {
85
+ /** A line of text where the list would be (an error, nothing found), in the room the list takes in
86
+ * the theater layout, so showing it does not change the library's height. */
87
+ function Message({ theater, children }) {
88
+ return theater ? (_jsx(Flex, { align: "center", css: { ...THEATER_STAGE, overflow: 'hidden' }, children: children })) : (_jsx(_Fragment, { children: children }));
89
+ }
90
+ /** A labelled round button, for the actions of the album or playlist that is open. */
91
+ function PillButton({ icon, label, onClick, primary, compact, }) {
92
+ // Built like a tab (a button with the same padding), so the row they share does not change height.
93
+ 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 }))] }));
94
+ }
95
+ function BrowseRow({ item, queueable, onOpen, onPlay, onNext, onAdd, }) {
60
96
  // A row opens what can be opened and otherwise plays; an openable thing that can also be played
61
97
  // (an album) gets its own play button.
62
98
  const main = item.expandable ? onOpen : item.playable ? onPlay : undefined;
63
- 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", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: item.title }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: item.subtitle })) : null] })] }), item.expandable && item.playable ? (_jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, onClick: onPlay })) : null] }));
99
+ 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] }));
64
100
  }
101
+ /** The room the theater row takes, whatever it holds: it asks for full-size cards and gives way when
102
+ * there is less, and is a size container so the cards can shrink to fit. What stands in for the row
103
+ * while it loads, or when it is empty, takes the same room, so the library does not change height. */
104
+ const THEATER_STAGE = {
105
+ containerType: 'size',
106
+ flex: '0 1 272px',
107
+ minHeight: 192,
108
+ };
65
109
  /** The width and height of a theater card's artwork when there is room for it. */
66
110
  const CARD = 200;
67
111
  /** The smallest a theater card's artwork gets: when there is less room than that, the page scrolls instead. */
@@ -73,7 +117,7 @@ const CARD_SIZE = `min(${CARD}px, max(${MIN_CARD}px, calc(100cqh - ${LABEL}px)))
73
117
  /** An item as a large card for the theater layout: big artwork (round for an artist), then the
74
118
  * title and subtitle. It opens or plays like a row does, and an album or playlist gets its play
75
119
  * button over the artwork's corner. */
76
- function BrowseCard({ item, onOpen, onPlay, }) {
120
+ function BrowseCard({ item, queueable, onOpen, onPlay, onAdd, }) {
77
121
  const main = item.expandable ? onOpen : item.playable ? onPlay : undefined;
78
- 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", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: item.title }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: item.subtitle })) : null] })] }), item.expandable && item.playable ? (_jsx(Box, { position: "absolute", css: { right: 10, top: `calc(${CARD_SIZE} - 54px)` }, children: _jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, onClick: onPlay }) })) : null] }));
122
+ 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] }));
79
123
  }
@@ -16,6 +16,7 @@ import { Cover } from '../layout/cover.js';
16
16
  import { PlainButton } from '../layout/plain-button.js';
17
17
  import { MediaBrowser } from './media-browser.js';
18
18
  import { MediaPlayerFull } from './media-player-full.js';
19
+ import { MediaQueue } from './media-queue.js';
19
20
  import { formatDuration, useMediaPosition } from './media-progress.js';
20
21
  import { useSeekHold } from './media-seek.js';
21
22
  import { SeekLine } from './seek-line.js';
@@ -74,5 +75,5 @@ export function MediaPlayerBar({ entity, name, browse }) {
74
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] }));
75
76
  // Holding the card, the artwork and the browse button open the same drawer: the player laid out
76
77
  // like the vertical one, with the library below it.
77
- 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 } : {}) }), children: (open, openExpanded) => bar(open, openExpanded) })) : (bar());
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());
78
79
  }
@@ -8,6 +8,7 @@ import { DrawerTrigger } from '../layout/use-drawer.js';
8
8
  import { useHold } from '../layout/use-hold.js';
9
9
  import { MediaBrowser } from './media-browser.js';
10
10
  import { MediaPlayerFull } from './media-player-full.js';
11
+ import { MediaQueue } from './media-queue.js';
11
12
  import { NowPlaying } from './now-playing.js';
12
13
  /** The player as an upright card for a narrow column beside a dashboard (a quarter to a third of
13
14
  * the width): the artwork in a ring that shows (and seeks) the position, title, transport and a
@@ -23,7 +24,7 @@ export function MediaPlayerColumn({ entity, name, browse }) {
23
24
  : (browse ??
24
25
  (ref && player?.capabilities.browse ? (_jsx(MediaBrowser, { entity: ref, layout: "auto" })) : undefined));
25
26
  const fallback = fallbackName(entity);
26
- 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, ...(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 } : {}) }) }));
27
+ 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 } : {}) }) }));
27
28
  }
28
29
  /** The card itself; with `open`, holding it (anywhere but on a control) calls it. */
29
30
  function Card({ open, children }) {
@@ -7,6 +7,8 @@ export interface MediaPlayerFullProps {
7
7
  entity: EntityRef | EntityHandle<'mediaPlayer'>;
8
8
  /** What goes below the player, usually the player's library: `<MediaBrowser entity="ma:living_room" layout="theater" />`. Left out, only the player is drawn. */
9
9
  browser?: ReactNode;
10
+ /** What goes beside the player in a wide space, usually the player's queue: `<MediaQueue entity="ma:living_room" />`. Only used when `wide` and the player has a queue; the narrow layout has no room for it. */
11
+ queue?: ReactNode;
10
12
  /** Calls the player this instead of the name it reports. */
11
13
  name?: string;
12
14
  /** Gives the library the whole width, for one laid out to use it (the theater layout). Defaults to whether the drawer is expanded. */
@@ -17,4 +19,4 @@ export interface MediaPlayerFullProps {
17
19
  * in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
18
20
  * heading, other cards beside it). It fills the height it is given, so the page that holds it
19
21
  * decides how tall that is. */
20
- export declare function MediaPlayerFull({ entity, browser, wide, name }: MediaPlayerFullProps): import("@emotion/react/jsx-runtime").JSX.Element;
22
+ export declare function MediaPlayerFull({ entity, browser, queue, wide, name }: MediaPlayerFullProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -11,9 +11,9 @@ const FULL_SIZE = 260;
11
11
  * in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
12
12
  * heading, other cards beside it). It fills the height it is given, so the page that holds it
13
13
  * decides how tall that is. */
14
- export function MediaPlayerFull({ entity, browser, wide, name }) {
14
+ export function MediaPlayerFull({ entity, browser, queue, wide, name }) {
15
15
  const handle = useEntityHandle('mediaPlayer', entity);
16
16
  const { detail } = useDetail();
17
17
  const full = wide ?? detail?.expanded === true;
18
- return (_jsxs(Flex, { direction: "column", gap: 5, grow: 1, minHeight: 0, children: [_jsx(Flex, { align: "center", justify: "center", ...(full ? { grow: 1 } : {}), css: { flexShrink: 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 } : {}) }) }) }), 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] }));
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] }));
19
19
  }
@@ -0,0 +1,14 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import type { EntityRef } from '@hashsome/core';
3
+ export interface MediaQueueProps {
4
+ /** The media player, as a ref like `ma:living_room`. Shows nothing for a player with no queue. */
5
+ entity: EntityRef;
6
+ }
7
+ /**
8
+ * A player's queue: the tracks that have just played (dimmed), the one playing, and what comes next,
9
+ * as a list that scrolls. Tap a track to jump to it, the cross takes it out, and Clear takes out everything
10
+ * after the track that is playing, which carries on. What comes next can be put in another order by
11
+ * dragging a track by its handle (or with the arrow keys on the handle). Once the last track has
12
+ * played and the player has stopped, that track is shown as played, not as playing. It is made to sit beside the player in a wide space (see `MediaPlayerFull`'s `queue`).
13
+ */
14
+ export declare function MediaQueue({ entity }: MediaQueueProps): import("@emotion/react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,151 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
+ import { Flex, Typography } from 'e-prim';
3
+ import { AnimatePresence, motion, Reorder, useDragControls, useReducedMotion } from 'motion/react';
4
+ import { useState } from 'react';
5
+ import { useEntityHandle } from '../hooks.js';
6
+ import { Icon } from '../icon.js';
7
+ import { Cover } from '../layout/cover.js';
8
+ import { MarqueeText } from '../layout/marquee-text.js';
9
+ import { PlainButton } from '../layout/plain-button.js';
10
+ import { useMediaQueue } from '../use-media-queue.js';
11
+ import { formatDuration } from './media-progress.js';
12
+ /** The height of a track's row: a touch target, with room for its picture. */
13
+ const ROW = 56;
14
+ /**
15
+ * A player's queue: the tracks that have just played (dimmed), the one playing, and what comes next,
16
+ * as a list that scrolls. Tap a track to jump to it, the cross takes it out, and Clear takes out everything
17
+ * after the track that is playing, which carries on. What comes next can be put in another order by
18
+ * dragging a track by its handle (or with the arrow keys on the handle). Once the last track has
19
+ * played and the player has stopped, that track is shown as played, not as playing. It is made to sit beside the player in a wide space (see `MediaPlayerFull`'s `queue`).
20
+ */
21
+ export function MediaQueue({ entity }) {
22
+ const handle = useEntityHandle('mediaPlayer', entity);
23
+ const { queue, refresh } = useMediaQueue(entity);
24
+ // Clearing a long queue takes a moment: the button waits, and what is being taken out is dimmed meanwhile.
25
+ const [clearing, setClearing] = useState(false);
26
+ // The order of what comes next while it is being changed, until the queue read afterwards replaces it.
27
+ // It belongs to the queue it was made from: a queue read afterwards leaves it behind.
28
+ const [draft, setDraft] = useState(null);
29
+ const still = useReducedMotion();
30
+ if (!queue || handle.entity?.capabilities.queue !== true) {
31
+ return null;
32
+ }
33
+ // After a change the queue is read again, once the player has had a moment to apply it.
34
+ const run = (command, item, shift) => {
35
+ const sent = command === 'clearQueue'
36
+ ? handle.command('clearQueue')
37
+ : command === 'moveQueueItem'
38
+ ? handle.command('moveQueueItem', { item: item.id, shift: shift })
39
+ : handle.command(command, { item: item.id });
40
+ if (command === 'clearQueue') {
41
+ setClearing(true);
42
+ }
43
+ void sent
44
+ .then(() => setTimeout(refresh, 400))
45
+ .catch(() => setDraft(null))
46
+ .finally(() => setClearing(false));
47
+ };
48
+ const at = queue.items.findIndex((x) => x.current);
49
+ const firstUpcoming = at === -1 ? queue.items.length : at + 1;
50
+ const leading = queue.items.slice(0, firstUpcoming);
51
+ const upcoming = queue.items.slice(firstUpcoming);
52
+ // A draft only counts while it is a reordering of the very tracks that are there.
53
+ const byId = new Map(upcoming.map((item) => [item.id, item]));
54
+ const shown = draft?.of === queue &&
55
+ draft.ids.length === upcoming.length &&
56
+ draft.ids.every((id) => byId.has(id))
57
+ ? draft.ids.map((id) => byId.get(id))
58
+ : upcoming;
59
+ // The player has stopped on the last track and its position is at the end: it is over. A track
60
+ // that was merely paused or stopped part way through is not, whatever state the player reports.
61
+ const { playback, position, duration } = handle.entity;
62
+ const stopped = (playback === 'idle' || playback === 'off') &&
63
+ position !== undefined &&
64
+ duration !== undefined &&
65
+ position >= duration - 2;
66
+ const finished = stopped &&
67
+ at !== -1 &&
68
+ at === queue.items.length - 1 &&
69
+ queue.offset + queue.items.length === queue.total;
70
+ // Dropping a track puts it where the draft has it: the command says by how many places it moved.
71
+ const drop = (item) => {
72
+ const to = shown.findIndex((x) => x.id === item.id);
73
+ const from = upcoming.findIndex((x) => x.id === item.id);
74
+ if (to === from) {
75
+ setDraft(null);
76
+ return;
77
+ }
78
+ run('moveQueueItem', item, to - from);
79
+ };
80
+ // The same from the keyboard: one place up or down, straight away.
81
+ const step = (item, by) => {
82
+ const from = shown.findIndex((x) => x.id === item.id);
83
+ const to = from + by;
84
+ if (to < 0 || to >= shown.length) {
85
+ return;
86
+ }
87
+ const ids = shown.map((x) => x.id);
88
+ ids.splice(to, 0, ids.splice(from, 1)[0]);
89
+ setDraft({ of: queue, ids });
90
+ run('moveQueueItem', item, by);
91
+ };
92
+ const hidden = Math.max(0, queue.total - queue.offset - queue.items.length);
93
+ return (_jsxs(Flex, { direction: "column", minHeight: 0, grow: 1, gap: 2, children: [_jsxs(Flex, { align: "center", justify: "space-between", gap: 2, children: [_jsxs(Typography, { as: "h2", variant: "heading", m: 0, children: ["Queue", _jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", children: [' ', "\u00B7 ", queue.total, " ", queue.total === 1 ? 'track' : 'tracks'] })] }), queue.total > 0 ? (_jsxs(PlainButton, { "aria-label": "Clear the queue", title: "Clear what comes next; the track playing carries on", disabled: clearing, onClick: () => run('clearQueue'), align: "center", gap: 1, px: 2.5, height: 32, radius: "full", color: "textMuted", css: ({ palette }) => ({ '&:hover': { background: palette.surfaceRaised } }), children: [_jsx(Icon, { name: "lu:trash", size: 14 }), _jsx(Typography, { as: "span", variant: "label", color: "textMuted", children: "Clear" })] })) : null] }), queue.items.length === 0 ? (_jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "Nothing is queued. Play or add something from the library." })) : (_jsxs(Reorder.Group, { as: "ul", axis: "y", values: shown.map((item) => item.id), onReorder: (ids) => setDraft({ of: queue, ids }), css: ({ density }) => ({
94
+ display: 'flex',
95
+ flexDirection: 'column',
96
+ gap: density.space / 6,
97
+ margin: 0,
98
+ padding: 0,
99
+ minHeight: 0,
100
+ listStyle: 'none',
101
+ overflowY: 'auto',
102
+ }), children: [_jsxs(AnimatePresence, { initial: false, children: [leading.map((item, index) => (_jsx(QueueRow, { item: item, still: still === true, dim: index < at || (finished && item.current === true), played: finished && item.current === true, onPlay: () => run('playQueueItem', item) }, item.id))), shown.map((item) => (_jsx(QueueRow, { item: item, still: still === true,
103
+ // While Clear works only what is on its way out is dimmed.
104
+ dim: clearing, reorderable: true, onPlay: () => run('playQueueItem', item), onRemove: () => run('removeQueueItem', item), onDrop: () => drop(item), onStep: (by) => step(item, by) }, item.id)))] }), hidden > 0 ? (_jsxs(Typography, { as: "li", variant: "secondary", color: "textMuted", py: 2, css: { flex: 'none' }, children: ["and ", hidden.toLocaleString(), " more"] })) : null] }))] }));
105
+ }
106
+ /** Room for the grip that a track in what comes next has, so every row lines up. */
107
+ const GRIP = 20;
108
+ function QueueRow({ item, still, dim, played = false, reorderable = false, onPlay, onRemove, onDrop, onStep, }) {
109
+ const controls = useDragControls();
110
+ const [dragging, setDragging] = useState(false);
111
+ const playing = item.current === true && !played;
112
+ const shared = {
113
+ initial: { opacity: 0, height: 0 },
114
+ animate: { opacity: dim ? 0.5 : 1, height: ROW },
115
+ exit: { opacity: 0, height: 0 },
116
+ transition: { duration: still ? 0 : 0.2, ease: 'easeOut' },
117
+ };
118
+ const css = ({ palette }) => ({
119
+ display: 'flex',
120
+ alignItems: 'center',
121
+ gap: 4,
122
+ flex: 'none',
123
+ overflow: 'hidden',
124
+ borderRadius: 8,
125
+ background: dragging ? palette.surfaceRaised : 'transparent',
126
+ boxShadow: dragging ? '0 6px 18px rgba(0, 0, 0, 0.18)' : 'none',
127
+ position: 'relative',
128
+ zIndex: dragging ? 1 : 0,
129
+ });
130
+ const content = (_jsxs(_Fragment, { children: [reorderable ? (_jsx(PlainButton, { "aria-label": `Move ${item.title}`, title: "Drag to move, or use the arrow keys", center: true, width: GRIP, height: ROW, color: "textMuted", css: {
131
+ flex: 'none',
132
+ cursor: 'grab',
133
+ touchAction: 'none',
134
+ '&:active': { cursor: 'grabbing' },
135
+ }, onPointerDown: (event) => controls.start(event), onKeyDown: (event) => {
136
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
137
+ event.preventDefault();
138
+ onStep?.(event.key === 'ArrowUp' ? -1 : 1);
139
+ }
140
+ }, children: _jsx(Icon, { name: "lu:grip-vertical", size: 16 }) })) : (_jsx(Flex, { width: GRIP, css: { flex: 'none' } })), _jsxs(PlainButton, { align: "center", gap: 3, grow: 1, minWidth: 0, height: ROW, px: 2, radius: "row", "aria-label": `${playing ? 'Playing' : 'Play'} ${item.title}`, "aria-current": playing ? 'true' : undefined, background: playing ? 'surfaceRaised' : 'transparent', onClick: onPlay, css: ({ palette }) => ({
141
+ transition: 'background-color 160ms ease',
142
+ '&:hover': { background: palette.surfaceRaised },
143
+ }), children: [_jsx(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: playing ? 'accent' : 'textMuted', radius: "small", width: 40, height: 40, overflow: "hidden", css: { flex: 'none' }, children: item.artworkUrl ? (_jsx(Cover, { src: item.artworkUrl })) : (_jsx(Icon, { name: playing ? 'lu:audio-lines' : 'lu:music', size: 18 })) }), _jsxs(Flex, { direction: "column", minWidth: 0, grow: 1, align: "flex-start", children: [_jsx(Typography, { as: "span", variant: "bodyStrong", color: playing ? 'accent' : 'text', maxWidth: "100%", children: _jsx(MarqueeText, { children: item.title }) }), item.artist ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", maxWidth: "100%", children: _jsx(MarqueeText, { children: item.artist }) })) : null] }), item.duration !== undefined ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: formatDuration(item.duration) })) : null] }), onRemove ? (_jsx(PlainButton, { "aria-label": `Remove ${item.title} from the queue`, title: "Remove from the queue", onClick: onRemove, center: true, width: ROW / 2, height: ROW / 2, radius: "full", color: "textMuted", css: ({ palette }) => ({
144
+ flex: 'none',
145
+ '&:hover': { background: palette.surfaceRaised },
146
+ }), children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : (_jsx(Flex, { width: ROW / 2, css: { flex: 'none' } }))] }));
147
+ return reorderable ? (_jsx(Reorder.Item, { value: item.id, dragListener: false, dragControls: controls, onDragStart: () => setDragging(true), onDragEnd: () => {
148
+ setDragging(false);
149
+ onDrop?.();
150
+ }, ...shared, css: css, children: content })) : (_jsx(motion.li, { ...shared, css: css, children: content }));
151
+ }
@@ -143,6 +143,12 @@ function StatusChip({ children }) {
143
143
  function WeatherChip({ entity }) {
144
144
  return useEntity(entity)?.kind === 'weather' ? (_jsx(WeatherPill, { entity: entity })) : (_jsx(SensorWeather, { entity: entity }));
145
145
  }
146
+ /** Text boxes cut to the height of the digits, so the reading and the range, in different sizes, are
147
+ * both centred on their digits and not on the font's line box (which sits a little low or high).
148
+ * Where the browser does not know `text-box` the text keeps its usual box. */
149
+ /** The height of one line of the 12px label text. */
150
+ const LABEL_LINE = 15;
151
+ const TRIM_TO_DIGITS = { textBox: 'trim-both cap alphabetic' };
146
152
  function WeatherPill({ entity }) {
147
153
  const handle = useEntityHandle('weather', entity);
148
154
  const { status } = handle;
@@ -157,9 +163,9 @@ function WeatherPill({ entity }) {
157
163
  const range = today?.temperature !== undefined && today.low !== undefined
158
164
  ? `${Math.round(today.temperature)}° / ${Math.round(today.low)}°`
159
165
  : undefined;
160
- const chip = (_jsxs(StatusChip, { children: [_jsx(Flex, { as: "span", children: _jsx(WeatherIcon, { condition: condition, size: 14 }) }), _jsx(Typography, { as: "span", variant: "label", children: ready
166
+ const chip = (_jsxs(StatusChip, { children: [_jsx(Flex, { as: "span", css: { minHeight: LABEL_LINE }, children: _jsx(WeatherIcon, { condition: condition, size: 14 }) }), _jsx(Typography, { as: "span", variant: "label", css: TRIM_TO_DIGITS, children: ready
161
167
  ? `${Math.round(weather.temperature ?? 0)}°`
162
- : statusLabels[status] }), ready && range ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: range })) : null] }));
168
+ : statusLabels[status] }), ready && range ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", css: TRIM_TO_DIGITS, children: range })) : null] }));
163
169
  if (!forecastable) {
164
170
  return chip;
165
171
  }
@@ -88,7 +88,7 @@ function WindMetric({ point, unit }) {
88
88
  }
89
89
  /** A tile with a label and a value, for the current readings of the expanded drawer. */
90
90
  function Stat({ label, children }) {
91
- return (_jsxs(Flex, { direction: "column", gap: 0.5, background: "surfaceRaised", radius: "card", px: 3, py: 2.5, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: label }), _jsx(Typography, { as: "span", variant: "bodyStrong", children: children })] }));
91
+ return (_jsxs(Flex, { direction: "column", gap: 0.5, border: true, radius: "row", px: 3, py: 2.5, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: label }), _jsx(Typography, { as: "span", variant: "bodyStrong", children: children })] }));
92
92
  }
93
93
  /** The readings the weather has now, from the entity and, for what it lacks, the hour that is
94
94
  * under way (a source may give UV or cloud cover only in its forecast). */
@@ -135,22 +135,9 @@ export function WeatherForecast({ entity, expanded: layout }) {
135
135
  notes: describeStep(hour, hourly.result),
136
136
  },
137
137
  ]
138
- : []), range: "1d", expanded: expanded, unit: hourly.result?.unit ?? '°', fitDomain: true, height: expanded ? 180 : 88 }), expanded ? (_jsx(Box, { mt: 2, css: { containerType: 'inline-size' }, children: _jsx(Box, { css: {
139
- display: 'grid',
140
- gap: 10,
141
- gridTemplateColumns: 'repeat(12, minmax(0, 1fr))',
142
- '@container (max-width: 1000px)': {
143
- gridTemplateColumns: 'repeat(8, minmax(0, 1fr))',
144
- },
145
- '@container (max-width: 640px)': {
146
- gridTemplateColumns: 'repeat(6, minmax(0, 1fr))',
147
- },
148
- '@container (max-width: 420px)': {
149
- gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
150
- },
151
- }, children: hours.map((hour) => {
138
+ : []), range: "1d", expanded: expanded, unit: hourly.result?.unit ?? '°', fitDomain: true, height: expanded ? 180 : 88 }), expanded ? (_jsx(Box, { mt: 2, children: _jsx(Flex, { css: { overflowX: 'auto', paddingBottom: 4 }, children: hours.map((hour) => {
152
139
  const rain = rainText(hour, hourly.result?.precipitationUnit);
153
- return (_jsxs(Flex, { direction: "column", align: "center", gap: 1.5, background: "surfaceRaised", radius: "card", px: 2, py: 3, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: hourName(hour.timestamp) }), _jsx(ConditionIcon, { condition: hour.condition, size: 26 }), _jsx(Typography, { as: "span", variant: "bodyStrong", children: degrees(hour.temperature) }), _jsxs(Flex, { direction: "column", align: "center", gap: 0.5, children: [rain ? _jsx(Metric, { icon: "lu:umbrella", children: rain }) : null, _jsx(WindMetric, { point: hour, unit: windUnit }), hour.humidity !== undefined ? (_jsxs(Metric, { icon: "lu:droplets", children: [Math.round(hour.humidity), "%"] })) : null, hour.uvIndex !== undefined && hour.uvIndex > 0 ? (_jsxs(Metric, { icon: "lu:sun", children: ["UV ", tenths(hour.uvIndex)] })) : null] })] }, hour.timestamp));
140
+ return (_jsxs(Flex, { direction: "column", align: "center", gap: 1.5, px: 2, py: 1, css: { flex: '1 0 84px' }, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: hourName(hour.timestamp) }), _jsx(ConditionIcon, { condition: hour.condition, size: 26 }), _jsx(Typography, { as: "span", variant: "bodyStrong", children: degrees(hour.temperature) }), _jsxs(Flex, { direction: "column", align: "center", gap: 0.5, children: [rain ? _jsx(Metric, { icon: "lu:umbrella", children: rain }) : null, _jsx(WindMetric, { point: hour, unit: windUnit }), hour.humidity !== undefined ? (_jsxs(Metric, { icon: "lu:droplets", children: [Math.round(hour.humidity), "%"] })) : null, hour.uvIndex !== undefined && hour.uvIndex > 0 ? (_jsxs(Metric, { icon: "lu:sun", children: ["UV ", tenths(hour.uvIndex)] })) : null] })] }, hour.timestamp));
154
141
  }) }) })) : (_jsx(Flex, { justify: "space-between", mt: 1, children: along.map((hour) => (_jsxs(Flex, { direction: "column", align: "center", gap: 1, grow: 1, css: { flexBasis: 0 }, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: hourName(hour.timestamp) }), _jsx(ConditionIcon, { condition: hour.condition, size: 18 }), _jsx(Typography, { as: "span", variant: "label", children: degrees(hour.temperature) })] }, hour.timestamp))) }))] })) : null, days.length > 0 ? (_jsxs(Flex, { direction: "column", gap: 2, children: [_jsxs(Typography, { as: "h3", variant: "label", color: "textMuted", m: 0, children: [days.length, " days"] }), _jsx(Flex, { as: "ul", direction: "column", gap: 1, m: 0, p: 0, css: { listStyle: 'none' }, children: days.map((day) => {
155
142
  const rain = rainText(day, daily.result?.precipitationUnit);
156
143
  return (_jsxs(Flex, { as: "li", align: "center", gap: 3, py: 1.5, children: [_jsx(Typography, { as: "span", variant: "body", width: 104, noWrap: true, children: dayName(day.timestamp, now) }), _jsx(ConditionIcon, { condition: day.condition, size: 20 }), _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", width: expanded ? 90 : 40, noWrap: true, children: rain ?? '' }), _jsx(Typography, { as: "span", variant: "label", color: "textMuted", width: 36, css: { textAlign: 'right' }, children: degrees(day.low) }), _jsx(Box, { grow: 1, height: 6, radius: "full", background: "surfaceRaised", position: "relative", children: span > 0 && day.low !== undefined && day.temperature !== undefined ? (_jsx(Box, { position: "absolute", height: 6, radius: "full", background: "accent", css: {
@@ -1,4 +1,10 @@
1
- import { LocalClient, MockIntegration } from '@hashsome/core';
1
+ import { LocalClient, MockIntegration, type BrowseQuery, type BrowseResult } from '@hashsome/core';
2
+ /** The gallery's backend: the mock, with one player whose shelves never finish loading, to show the
3
+ * loading state of the library. */
4
+ declare class GalleryIntegration extends MockIntegration {
5
+ browse(entityId: string, query: BrowseQuery): Promise<BrowseResult>;
6
+ }
2
7
  /** Mock entities covering every state the components handle. Refs are `ha:<id>`. */
3
- export declare function createGalleryIntegration(): MockIntegration;
8
+ export declare function createGalleryIntegration(): GalleryIntegration;
4
9
  export declare function createGalleryClient(): LocalClient;
10
+ export {};
@@ -4,9 +4,18 @@ import { LocalClient, mockAction, mockClimate, mockLibrary, mockLight, mockMedia
4
4
  const DEMO_ARTWORK = `${import.meta.env.BASE_URL}artwork.jpg`;
5
5
  const power = (watts) => mockSensor({ name: 'Power', value: watts, unit: 'W', measurement: 'power' });
6
6
  const energy = (kwh) => mockSensor({ name: 'Energy', value: kwh, unit: 'kWh', measurement: 'energy' });
7
+ /** The gallery's backend: the mock, with one player whose shelves never finish loading, to show the
8
+ * loading state of the library. */
9
+ class GalleryIntegration extends MockIntegration {
10
+ browse(entityId, query) {
11
+ return entityId === 'media_player.loading' && query.path !== undefined
12
+ ? new Promise(() => { })
13
+ : super.browse(entityId, query);
14
+ }
15
+ }
7
16
  /** Mock entities covering every state the components handle. Refs are `ha:<id>`. */
8
17
  export function createGalleryIntegration() {
9
- return new MockIntegration({
18
+ return new GalleryIntegration({
10
19
  library: mockLibrary(),
11
20
  entities: {
12
21
  'light.plain_on': mockLight({ name: 'Lamp', on: true }),
@@ -108,7 +117,11 @@ export function createGalleryIntegration() {
108
117
  duration: 231,
109
118
  positionUpdatedAt: new Date().toISOString(),
110
119
  shuffle: false,
111
- capabilities: { browse: true, search: true, seek: true, shuffle: true },
120
+ capabilities: { browse: true, search: true, seek: true, shuffle: true, queue: true },
121
+ }),
122
+ 'media_player.loading': mockMediaPlayer({
123
+ name: 'Slow library',
124
+ capabilities: { browse: true, search: true },
112
125
  }),
113
126
  'media_player.off': mockMediaPlayer({
114
127
  name: 'Kitchen Speaker',