@hashsome/ui 0.1.0 → 0.3.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 (127) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/entities/artwork-ring.d.ts +1 -1
  3. package/dist/entities/artwork-ring.js +40 -14
  4. package/dist/entities/climate-tile.d.ts +1 -1
  5. package/dist/entities/climate-tile.js +1 -1
  6. package/dist/entities/light-tile.d.ts +1 -1
  7. package/dist/entities/light-tile.js +1 -1
  8. package/dist/entities/media-browser.js +76 -20
  9. package/dist/entities/media-player-bar.js +3 -2
  10. package/dist/entities/media-player-column.js +3 -2
  11. package/dist/entities/media-player-full.d.ts +22 -0
  12. package/dist/entities/media-player-full.js +19 -0
  13. package/dist/entities/media-queue.d.ts +14 -0
  14. package/dist/entities/media-queue.js +151 -0
  15. package/dist/entities/now-playing.d.ts +1 -1
  16. package/dist/entities/now-playing.js +1 -1
  17. package/dist/entities/top-bar.d.ts +1 -1
  18. package/dist/entities/top-bar.js +26 -23
  19. package/dist/entities/weather-forecast.d.ts +13 -0
  20. package/dist/entities/weather-forecast.js +148 -0
  21. package/dist/entities/weather-icon.d.ts +7 -0
  22. package/dist/entities/weather-icon.js +11 -0
  23. package/dist/entities/weather-look.d.ts +17 -0
  24. package/dist/entities/weather-look.js +75 -0
  25. package/dist/gallery/fixtures.d.ts +8 -2
  26. package/dist/gallery/fixtures.js +22 -3
  27. package/dist/gallery/gallery.js +7 -5
  28. package/dist/gallery/props-data.js +66 -8
  29. package/dist/hooks.d.ts +6 -0
  30. package/dist/hooks.js +18 -1
  31. package/dist/icon.d.ts +3 -1
  32. package/dist/icon.js +6 -2
  33. package/dist/index.d.ts +3 -1
  34. package/dist/index.js +3 -1
  35. package/dist/layout/drawer-controls.d.ts +2 -0
  36. package/dist/layout/drawer-controls.js +11 -1
  37. package/dist/layout/history-section.d.ts +3 -13
  38. package/dist/layout/history-section.js +15 -5
  39. package/dist/layout/logbook-history.d.ts +6 -0
  40. package/dist/layout/logbook-history.js +9 -0
  41. package/dist/layout/marquee-text.d.ts +11 -0
  42. package/dist/layout/marquee-text.js +67 -0
  43. package/dist/layout/reveal.d.ts +11 -0
  44. package/dist/layout/reveal.js +19 -0
  45. package/dist/layout/series-chart.d.ts +5 -1
  46. package/dist/layout/series-chart.js +9 -4
  47. package/dist/layout/skeleton.d.ts +11 -0
  48. package/dist/layout/skeleton.js +16 -0
  49. package/dist/layout/tile.d.ts +9 -1
  50. package/dist/layout/tile.js +8 -4
  51. package/dist/use-entity-history.js +5 -1
  52. package/dist/use-entity-logbook.d.ts +13 -0
  53. package/dist/use-entity-logbook.js +46 -0
  54. package/dist/use-media-browser.d.ts +4 -0
  55. package/dist/use-media-browser.js +11 -3
  56. package/dist/use-media-queue.d.ts +15 -0
  57. package/dist/use-media-queue.js +44 -0
  58. package/dist/use-weather-forecast.d.ts +12 -0
  59. package/dist/use-weather-forecast.js +45 -0
  60. package/package.json +2 -2
  61. package/dist/entities/action-button.js.map +0 -1
  62. package/dist/entities/artwork-ring.js.map +0 -1
  63. package/dist/entities/climate-tile.js.map +0 -1
  64. package/dist/entities/light-color.js.map +0 -1
  65. package/dist/entities/light-tile.js.map +0 -1
  66. package/dist/entities/media-browser.js.map +0 -1
  67. package/dist/entities/media-controls.js.map +0 -1
  68. package/dist/entities/media-player-bar.js.map +0 -1
  69. package/dist/entities/media-player-body.d.ts +0 -16
  70. package/dist/entities/media-player-body.js +0 -18
  71. package/dist/entities/media-player-body.js.map +0 -1
  72. package/dist/entities/media-player-column.js.map +0 -1
  73. package/dist/entities/media-player-page.d.ts +0 -12
  74. package/dist/entities/media-player-page.js +0 -13
  75. package/dist/entities/media-player-page.js.map +0 -1
  76. package/dist/entities/media-progress.js.map +0 -1
  77. package/dist/entities/media-seek.js.map +0 -1
  78. package/dist/entities/nav-dock.js.map +0 -1
  79. package/dist/entities/nav-rail.js.map +0 -1
  80. package/dist/entities/now-playing.js.map +0 -1
  81. package/dist/entities/scene-button.js.map +0 -1
  82. package/dist/entities/seek-line.js.map +0 -1
  83. package/dist/entities/sensor-readout.js.map +0 -1
  84. package/dist/entities/top-bar.js.map +0 -1
  85. package/dist/entity-handle.js.map +0 -1
  86. package/dist/gallery/fixtures.js.map +0 -1
  87. package/dist/gallery/gallery.js.map +0 -1
  88. package/dist/gallery/index.js.map +0 -1
  89. package/dist/gallery/props-data.js.map +0 -1
  90. package/dist/hooks.js.map +0 -1
  91. package/dist/icon-data.js.map +0 -1
  92. package/dist/icon.js.map +0 -1
  93. package/dist/index.js.map +0 -1
  94. package/dist/layout/cover.js.map +0 -1
  95. package/dist/layout/date-time-field.js.map +0 -1
  96. package/dist/layout/detail-provider.js.map +0 -1
  97. package/dist/layout/drawer-controls.js.map +0 -1
  98. package/dist/layout/energy-chart.js.map +0 -1
  99. package/dist/layout/energy-usage.js.map +0 -1
  100. package/dist/layout/entity-drawer.js.map +0 -1
  101. package/dist/layout/fade-scroll.js.map +0 -1
  102. package/dist/layout/grid.js.map +0 -1
  103. package/dist/layout/history-section.js.map +0 -1
  104. package/dist/layout/hold-progress.js.map +0 -1
  105. package/dist/layout/icon-button-styles.js.map +0 -1
  106. package/dist/layout/page.js.map +0 -1
  107. package/dist/layout/plain-button.js.map +0 -1
  108. package/dist/layout/room-header.js.map +0 -1
  109. package/dist/layout/round-button.js.map +0 -1
  110. package/dist/layout/sensor-history.js.map +0 -1
  111. package/dist/layout/sensor-stats.js.map +0 -1
  112. package/dist/layout/series-chart.js.map +0 -1
  113. package/dist/layout/stepper.js.map +0 -1
  114. package/dist/layout/swatch-row.js.map +0 -1
  115. package/dist/layout/tile.js.map +0 -1
  116. package/dist/layout/use-drawer.js.map +0 -1
  117. package/dist/layout/use-hold.js.map +0 -1
  118. package/dist/provider.js.map +0 -1
  119. package/dist/status.js.map +0 -1
  120. package/dist/theme/density.js.map +0 -1
  121. package/dist/theme/global-styles.js.map +0 -1
  122. package/dist/theme/index.js.map +0 -1
  123. package/dist/theme/overrides.js.map +0 -1
  124. package/dist/theme/tokens.js.map +0 -1
  125. package/dist/theme/use-typography.js.map +0 -1
  126. package/dist/use-entity-history.js.map +0 -1
  127. package/dist/use-media-browser.js.map +0 -1
@@ -2,7 +2,7 @@ import type { SeekHold } from './media-seek.ts';
2
2
  /** The player's artwork in a circle, like the one in the bar but larger: an inner outline around
3
3
  * it, and an outer ring that shows how far along playback is. With `onSeek` the ring is a slider:
4
4
  * drag around it, or use the arrow keys. Not exported from the package: `MediaPlayerColumn` and
5
- * `MediaPlayerPage` draw it. */
5
+ * `MediaPlayerFull` draw it. */
6
6
  export declare function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size: SIZE, }: {
7
7
  artworkUrl?: string | undefined;
8
8
  /** The position to draw and the way to change it, shared with whatever shows the time. */
@@ -9,23 +9,47 @@ const DEFAULT_SIZE = 168;
9
9
  /** The player's artwork in a circle, like the one in the bar but larger: an inner outline around
10
10
  * it, and an outer ring that shows how far along playback is. With `onSeek` the ring is a slider:
11
11
  * drag around it, or use the arrow keys. Not exported from the package: `MediaPlayerColumn` and
12
- * `MediaPlayerPage` draw it. */
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
  }
@@ -12,7 +12,7 @@ export interface ClimateTileProps {
12
12
  icon?: IconName;
13
13
  /** Power-draw chart for the drawer, above `history`. No default source yet — pass explicitly. */
14
14
  energy?: EnergyChartProps;
15
- /** Recent activity, shown in the drawer. No default source yet — pass explicitly. */
15
+ /** Recent activity, shown in the drawer: who or what changed it, and when. Left out, it comes from the backend's own record when it keeps one (Home Assistant's logbook), fetched when the drawer opens. */
16
16
  history?: LogbookEntry[];
17
17
  }
18
18
  /** Heater / thermostat: a mode button that opens mode swatches in the card, a target temperature
@@ -44,7 +44,7 @@ export function ClimateTile({ entity, name, icon, energy, history }) {
44
44
  temperature: Math.min(max, Math.max(min, Math.round(value * 100) / 100)),
45
45
  });
46
46
  const setMode = (next) => void handle.command('setMode', { mode: next });
47
- return (_jsx(Tile, { label: name ?? climate?.name ?? fallbackName(entity), icon: icon ?? 'lu:thermometer', kind: "Climate", status: status, secondary: current !== undefined ? `${current} ${unit}` : undefined, detail: _jsx(ClimateDetailBody, { climate: climate, mode: mode, modes: modes, target: target, current: current, step: step, min: min, max: max, unit: unit, onMode: setMode, onTarget: setTarget, onPreset: (preset) => void handle.command('setPreset', { preset }) }), ...(energy ? { energy } : {}), ...(history ? { history } : {}), ...(ready && picking
47
+ return (_jsx(Tile, { label: name ?? climate?.name ?? fallbackName(entity), icon: icon ?? 'lu:thermometer', kind: "Climate", status: status, secondary: current !== undefined ? `${current} ${unit}` : undefined, detail: _jsx(ClimateDetailBody, { climate: climate, mode: mode, modes: modes, target: target, current: current, step: step, min: min, max: max, unit: unit, onMode: setMode, onTarget: setTarget, onPreset: (preset) => void handle.command('setPreset', { preset }) }), ...(energy ? { energy } : {}), ...(history ? { history } : {}), ...(typeof entity === 'string' ? { logbook: entity } : {}), ...(ready && picking
48
48
  ? {
49
49
  // A render prop `Tile` calls, not a component of its own.
50
50
  // oxlint-disable-next-line react/no-unstable-nested-components
@@ -15,7 +15,7 @@ export interface LightTileProps {
15
15
  /** Power-draw chart (e.g. a Shelly's energy monitoring), shown in the drawer above `history`.
16
16
  * No default source yet — pass explicitly. */
17
17
  energy?: EnergyChartProps;
18
- /** Recent activity, shown in the drawer. No default source yet — pass explicitly. */
18
+ /** Recent activity, shown in the drawer: who or what changed it, and when. Left out, it comes from the backend's own record when it keeps one (Home Assistant's logbook), fetched when the drawer opens. */
19
19
  history?: LogbookEntry[];
20
20
  /** Swatches shown when the palette button is opened. Default: four white temperatures. Each takes `kelvin` (color-temp lights) and/or `hs` (color lights) and is skipped if the light can't apply it. */
21
21
  colors?: LightColorPreset[];
@@ -49,7 +49,7 @@ export function LightTile({ entity, name, icon, energy, history, colors = DEFAUL
49
49
  const [hue, saturation] = rgbToHs(picked);
50
50
  void handle.command('setColor', { hue, saturation });
51
51
  }
52
- } })) : null }), ...(energy ? { energy } : {}), ...(history ? { history } : {}), ...(dimmable
52
+ } })) : null }), ...(energy ? { energy } : {}), ...(history ? { history } : {}), ...(typeof entity === 'string' ? { logbook: entity } : {}), ...(dimmable
53
53
  ? {
54
54
  fill: on ? (brightness ?? 1) : 0,
55
55
  onFillChange: (fill) => void handle.command('setBrightness', { brightness: fill }),
@@ -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,93 @@ 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: { listStyle: 'none', scrollSnapType: 'x proximity' }, 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))) }));
52
84
  }
53
- 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, }) {
54
96
  // A row opens what can be opened and otherwise plays; an openable thing that can also be played
55
97
  // (an album) gets its own play button.
56
98
  const main = item.expandable ? onOpen : item.playable ? onPlay : undefined;
57
- 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] }));
58
100
  }
59
- /** The width and height of a theater card's artwork. */
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
+ };
109
+ /** The width and height of a theater card's artwork when there is room for it. */
60
110
  const CARD = 200;
111
+ /** The smallest a theater card's artwork gets: when there is less room than that, the page scrolls instead. */
112
+ const MIN_CARD = 120;
113
+ /** What a theater card needs under its artwork for the title and subtitle. */
114
+ const LABEL = 56;
115
+ /** The artwork's side: `CARD`, or less when the row it sits in is shorter than the card and its labels (`cqh` is a percent of that row's height). */
116
+ const CARD_SIZE = `min(${CARD}px, max(${MIN_CARD}px, calc(100cqh - ${LABEL}px)))`;
61
117
  /** An item as a large card for the theater layout: big artwork (round for an artist), then the
62
118
  * title and subtitle. It opens or plays like a row does, and an album or playlist gets its play
63
119
  * button over the artwork's corner. */
64
- function BrowseCard({ item, onOpen, onPlay, }) {
120
+ function BrowseCard({ item, queueable, onOpen, onPlay, onAdd, }) {
65
121
  const main = item.expandable ? onOpen : item.playable ? onPlay : undefined;
66
- return (_jsxs(Box, { position: "relative", width: CARD, 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", width: CARD, height: CARD, 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: CARD - 54 }, 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] }));
67
123
  }
@@ -15,7 +15,8 @@ import { usePressFeedback, useVolumeControl } from './media-controls.js';
15
15
  import { Cover } from '../layout/cover.js';
16
16
  import { PlainButton } from '../layout/plain-button.js';
17
17
  import { MediaBrowser } from './media-browser.js';
18
- import { MediaPlayerBody } from './media-player-body.js';
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(MediaPlayerBody, { 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
  }
@@ -7,7 +7,8 @@ import { IconButton } from '../layout/tile.js';
7
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
- import { MediaPlayerBody } from './media-player-body.js';
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(MediaPlayerBody, { 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 }) {
@@ -0,0 +1,22 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import type { EntityRef } from '@hashsome/core';
3
+ import type { ReactNode } from 'react';
4
+ import { type EntityHandle } from '../entity-handle.ts';
5
+ export interface MediaPlayerFullProps {
6
+ /** The media player, as a ref like `ma:living_room`. */
7
+ entity: EntityRef | EntityHandle<'mediaPlayer'>;
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
+ 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;
12
+ /** Calls the player this instead of the name it reports. */
13
+ name?: string;
14
+ /** Gives the library the whole width, for one laid out to use it (the theater layout). Defaults to whether the drawer is expanded. */
15
+ wide?: boolean;
16
+ }
17
+ /** The big player: the player centered at the top, and the library (or whatever `browser` is) below
18
+ * it. It is what the media cards' drawers show, at every width, and it is the widget a dashboard puts
19
+ * in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
20
+ * heading, other cards beside it). It fills the height it is given, so the page that holds it
21
+ * decides how tall that is. */
22
+ export declare function MediaPlayerFull({ entity, browser, queue, wide, name }: MediaPlayerFullProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
+ import { Box, Flex } from 'e-prim';
3
+ import { fallbackName } from '../entity-handle.js';
4
+ import { useEntityHandle } from '../hooks.js';
5
+ import { useDetail } from '../layout/detail-provider.js';
6
+ import { NowPlaying } from './now-playing.js';
7
+ /** The artwork ring's diameter in a full-size player, against the usual 168px. */
8
+ const FULL_SIZE = 260;
9
+ /** The big player: the player centered at the top, and the library (or whatever `browser` is) below
10
+ * it. It is what the media cards' drawers show, at every width, and it is the widget a dashboard puts
11
+ * in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
12
+ * heading, other cards beside it). It fills the height it is given, so the page that holds it
13
+ * decides how tall that is. */
14
+ export function MediaPlayerFull({ entity, browser, queue, wide, name }) {
15
+ const handle = useEntityHandle('mediaPlayer', entity);
16
+ const { detail } = useDetail();
17
+ const full = wide ?? detail?.expanded === true;
18
+ return (_jsxs(Flex, { direction: "column", gap: 5, grow: 1, minHeight: 0, children: [_jsxs(Flex, { align: "stretch", justify: "center", gap: 6, ...(full ? { grow: 1 } : {}), css: { flexShrink: 0 }, children: [_jsx(Flex, { align: "center", justify: "center", grow: 1, minWidth: 0, children: _jsx(Box, { width: "100%", maxWidth: full ? 560 : 420, children: _jsx(NowPlaying, { handle: handle, fallback: fallbackName(entity), ...(name !== undefined ? { name } : {}), shuffle: "title", ...(full ? { size: FULL_SIZE } : {}) }) }) }), full && queue !== undefined && handle.entity?.capabilities.queue === true ? (_jsx(Flex, { position: "relative", width: 400, css: ({ palette }) => ({ flex: 'none', borderLeft: `1px solid ${palette.border}` }), children: _jsx(Flex, { direction: "column", position: "absolute", pl: 5, css: { inset: 0 }, children: queue }) })) : null] }), browser !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Box, { height: 1, background: "border", css: { flexShrink: 0 } }), _jsx(Flex, { direction: "column", width: "100%", minHeight: 0, ...(full ? {} : { maxWidth: 960, mx: 'auto' }), children: browser })] })) : null] }));
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
+ }
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
2
2
  import type { EntityHandle } from '../entity-handle.ts';
3
3
  /** The upright "now playing" panel the media column and page share: the artwork on a record, what is playing, how
4
4
  * far along it is, the transport buttons and a volume bar that is always there. Not exported from
5
- * the package; `MediaPlayerColumn` and `MediaPlayerPage` are its public forms. */
5
+ * the package; `MediaPlayerColumn` and `MediaPlayerFull` are its public forms. */
6
6
  export declare function NowPlaying({ handle, fallback, name, extra, onOpenArtwork, size, shuffle: shufflePlacement, }: {
7
7
  handle: EntityHandle<'mediaPlayer'>;
8
8
  /** What to call the player before it reports a name. */
@@ -12,7 +12,7 @@ import { useSeekHold } from './media-seek.js';
12
12
  import { ArtworkRing } from './artwork-ring.js';
13
13
  /** The upright "now playing" panel the media column and page share: the artwork on a record, what is playing, how
14
14
  * far along it is, the transport buttons and a volume bar that is always there. Not exported from
15
- * the package; `MediaPlayerColumn` and `MediaPlayerPage` are its public forms. */
15
+ * the package; `MediaPlayerColumn` and `MediaPlayerFull` are its public forms. */
16
16
  export function NowPlaying({ handle, fallback, name, extra, onOpenArtwork, size, shuffle: shufflePlacement = 'transport', }) {
17
17
  const player = handle.entity;
18
18
  const { status } = handle;
@@ -22,7 +22,7 @@ export interface TopBarProps {
22
22
  dashboards?: DashboardOption[];
23
23
  /** Compact circular buttons that fire `scene.turn_on` directly (not the `Tile`-based `SceneButton`); a bare entity ref uses the default icon and an auto-assigned color, or pass `{ entity, icon, color }` to pick either. */
24
24
  scenes?: (EntityRef | TopBarScene)[];
25
- /** Current weather as a pill with an icon for the sky and a rounded whole-degree reading. A weather entity, like `ha:weather.forecast_home`, shows its real condition; a plain sensor, e.g. an outdoor temperature, gets a fixed sun. */
25
+ /** Current weather as a pill with an icon for the sky and a rounded whole-degree reading. A weather entity, like `ha:weather.forecast_home`, shows its real condition, today's high and low once its forecast has loaded, and opens the forecast (the next 24 hours and the days ahead) when tapped, if its source has one; a plain sensor, e.g. an outdoor temperature, gets a fixed sun and no forecast. */
26
26
  weather?: EntityRef;
27
27
  /** Your own component(s) for the right-hand side, after the weather: a security mode picker, a custom status. Whatever it is, it sits in the bar's row and is yours to style. */
28
28
  extra?: ReactNode;