@hashsome/ui 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/debug.d.ts +27 -0
  3. package/dist/debug.js +95 -0
  4. package/dist/entities/artwork-ring.d.ts +4 -2
  5. package/dist/entities/artwork-ring.js +79 -51
  6. package/dist/entities/light-tile.d.ts +3 -1
  7. package/dist/entities/light-tile.js +29 -20
  8. package/dist/entities/media-browser.d.ts +3 -1
  9. package/dist/entities/media-browser.js +41 -15
  10. package/dist/entities/media-player-bar.d.ts +9 -1
  11. package/dist/entities/media-player-bar.js +122 -12
  12. package/dist/entities/media-player-column.d.ts +5 -2
  13. package/dist/entities/media-player-column.js +33 -5
  14. package/dist/entities/media-player-full.d.ts +12 -2
  15. package/dist/entities/media-player-full.js +45 -3
  16. package/dist/entities/media-seek.d.ts +4 -1
  17. package/dist/entities/media-seek.js +22 -3
  18. package/dist/entities/nav-dock.js +3 -3
  19. package/dist/entities/now-playing.d.ts +3 -1
  20. package/dist/entities/now-playing.js +8 -3
  21. package/dist/entities/seek-line.js +12 -0
  22. package/dist/entities/top-bar.d.ts +21 -0
  23. package/dist/entities/top-bar.js +19 -10
  24. package/dist/gallery/gallery.js +18 -3
  25. package/dist/gallery/props-data.js +187 -3
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.js +3 -0
  28. package/dist/layout/animated-outlet.d.ts +14 -0
  29. package/dist/layout/animated-outlet.js +44 -0
  30. package/dist/layout/board.d.ts +26 -0
  31. package/dist/layout/board.js +85 -0
  32. package/dist/layout/debug-menu.d.ts +11 -0
  33. package/dist/layout/debug-menu.js +99 -0
  34. package/dist/layout/detail-provider.d.ts +4 -2
  35. package/dist/layout/detail-provider.js +2 -2
  36. package/dist/layout/drawer-controls.js +36 -8
  37. package/dist/layout/entity-drawer.js +1 -1
  38. package/dist/layout/grid-overlay.d.ts +6 -0
  39. package/dist/layout/grid-overlay.js +143 -0
  40. package/dist/layout/hold-progress.d.ts +8 -2
  41. package/dist/layout/hold-progress.js +20 -4
  42. package/dist/layout/page.d.ts +7 -1
  43. package/dist/layout/page.js +43 -6
  44. package/dist/layout/room-header.js +1 -1
  45. package/dist/layout/tile.js +14 -10
  46. package/dist/layout/top-row.d.ts +9 -0
  47. package/dist/layout/top-row.js +10 -0
  48. package/dist/layout/use-drawer.d.ts +3 -2
  49. package/dist/layout/use-drawer.js +5 -3
  50. package/dist/provider.d.ts +15 -4
  51. package/dist/provider.js +144 -13
  52. package/dist/theme/global-styles.js +3 -0
  53. package/dist/theme/grid.d.ts +30 -0
  54. package/dist/theme/grid.js +38 -0
  55. package/dist/theme/index.js +2 -1
  56. package/dist/theme/schedule.d.ts +33 -0
  57. package/dist/theme/schedule.js +44 -0
  58. package/dist/theme/tokens.d.ts +2 -0
  59. package/package.json +2 -2
@@ -0,0 +1,27 @@
1
+ /** What the debug menu can set the theme to. `sun` follows the project's daylight sensor, so it is only offered when there is one. */
2
+ export type ThemeChoice = 'light' | 'dark' | 'system' | 'sun';
3
+ export declare const THEME_CHOICES: ThemeChoice[];
4
+ /** Whether the build or run was asked for the debug menu: `HASHSOME_DEBUG=1` (or `true`) in the
5
+ * environment of `hashsome dev` or `hashsome build`. A production build has it only if it was built with it. */
6
+ export declare function debugFromEnv(): boolean;
7
+ /** What the debug menu has set, kept on the device and handed down by `HashsomeProvider`. */
8
+ export interface DebugState {
9
+ /** The module grid is drawn over the page. */
10
+ grid: boolean;
11
+ setGrid: (on: boolean) => void;
12
+ /** The theme the menu chose, over the one the project configured; `null` until it chooses. */
13
+ themeChoice: ThemeChoice | null;
14
+ setThemeChoice: (choice: ThemeChoice | null) => void;
15
+ /** The page should be fullscreen: the first touch after a reload makes it so again. */
16
+ fullscreen: boolean;
17
+ setFullscreen: (on: boolean) => void;
18
+ }
19
+ export declare const DebugContext: import("react").Context<DebugState>;
20
+ /** What the debug menu has set; everything off outside a `HashsomeProvider`. */
21
+ export declare function useDebug(): DebugState;
22
+ /** The state `HashsomeProvider` keeps: read from the device once, written back on each change. */
23
+ export declare function useDebugState(): DebugState;
24
+ /** Keeps the page's fullscreen as `wanted` says. A browser only allows entering fullscreen in answer
25
+ * to a touch, so after a reload (which always leaves it) the first touch puts the page back; leaving
26
+ * it by the system's own gesture turns `wanted` off, so the next touch does not pull it back in. */
27
+ export declare function useFullscreenKept(wanted: boolean, setWanted: (on: boolean) => void): void;
package/dist/debug.js ADDED
@@ -0,0 +1,95 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
2
+ export const THEME_CHOICES = ['light', 'dark', 'system', 'sun'];
3
+ const GRID_KEY = 'hashsome:grid';
4
+ const THEME_KEY = 'hashsome:theme';
5
+ const FULLSCREEN_KEY = 'hashsome:fullscreen';
6
+ /** Whether the build or run was asked for the debug menu: `HASHSOME_DEBUG=1` (or `true`) in the
7
+ * environment of `hashsome dev` or `hashsome build`. A production build has it only if it was built with it. */
8
+ export function debugFromEnv() {
9
+ return typeof HASHSOME_DEBUG_ON !== 'undefined' && HASHSOME_DEBUG_ON === true;
10
+ }
11
+ function read(key) {
12
+ try {
13
+ return localStorage.getItem(key);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function write(key, value) {
20
+ try {
21
+ if (value === null) {
22
+ localStorage.removeItem(key);
23
+ }
24
+ else {
25
+ localStorage.setItem(key, value);
26
+ }
27
+ }
28
+ catch {
29
+ // Without storage the choice just lasts until the page is closed.
30
+ }
31
+ }
32
+ const OFF = {
33
+ grid: false,
34
+ setGrid: () => undefined,
35
+ themeChoice: null,
36
+ setThemeChoice: () => undefined,
37
+ fullscreen: false,
38
+ setFullscreen: () => undefined,
39
+ };
40
+ export const DebugContext = createContext(OFF);
41
+ /** What the debug menu has set; everything off outside a `HashsomeProvider`. */
42
+ export function useDebug() {
43
+ return useContext(DebugContext);
44
+ }
45
+ /** The state `HashsomeProvider` keeps: read from the device once, written back on each change. */
46
+ export function useDebugState() {
47
+ const [grid, setGridState] = useState(() => read(GRID_KEY) === 'on');
48
+ const [fullscreen, setFullscreenState] = useState(() => read(FULLSCREEN_KEY) === 'on');
49
+ const [themeChoice, setThemeState] = useState(() => {
50
+ const kept = read(THEME_KEY);
51
+ return THEME_CHOICES.find((choice) => choice === kept) ?? null;
52
+ });
53
+ // Stable: the setters are dependencies of effects, and a new one each render would run them each render.
54
+ const setGrid = useCallback((on) => {
55
+ write(GRID_KEY, on ? 'on' : null);
56
+ setGridState(on);
57
+ }, []);
58
+ const setThemeChoice = useCallback((choice) => {
59
+ write(THEME_KEY, choice);
60
+ setThemeState(choice);
61
+ }, []);
62
+ const setFullscreen = useCallback((on) => {
63
+ write(FULLSCREEN_KEY, on ? 'on' : null);
64
+ setFullscreenState(on);
65
+ }, []);
66
+ return useMemo(() => ({ grid, setGrid, themeChoice, setThemeChoice, fullscreen, setFullscreen }), [grid, setGrid, themeChoice, setThemeChoice, fullscreen, setFullscreen]);
67
+ }
68
+ /** Keeps the page's fullscreen as `wanted` says. A browser only allows entering fullscreen in answer
69
+ * to a touch, so after a reload (which always leaves it) the first touch puts the page back; leaving
70
+ * it by the system's own gesture turns `wanted` off, so the next touch does not pull it back in. */
71
+ export function useFullscreenKept(wanted, setWanted) {
72
+ useEffect(() => {
73
+ if (typeof document === 'undefined' || !document.fullscreenEnabled) {
74
+ return;
75
+ }
76
+ const enter = () => {
77
+ if (wanted && !document.fullscreenElement) {
78
+ document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => undefined);
79
+ }
80
+ };
81
+ const onChange = () => {
82
+ if (!document.fullscreenElement && wanted) {
83
+ setWanted(false);
84
+ }
85
+ };
86
+ document.addEventListener('fullscreenchange', onChange);
87
+ if (wanted && !document.fullscreenElement) {
88
+ window.addEventListener('pointerup', enter, { once: true });
89
+ }
90
+ return () => {
91
+ document.removeEventListener('fullscreenchange', onChange);
92
+ window.removeEventListener('pointerup', enter);
93
+ };
94
+ }, [wanted, setWanted]);
95
+ }
@@ -3,7 +3,7 @@ import type { SeekHold } from './media-seek.ts';
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
5
  * `MediaPlayerFull` draw it. */
6
- export declare function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size: SIZE, }: {
6
+ export declare function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size: SIZE, maxSize, }: {
7
7
  artworkUrl?: string | undefined;
8
8
  /** The position to draw and the way to change it, shared with whatever shows the time. */
9
9
  seek: SeekHold;
@@ -12,6 +12,8 @@ export declare function ArtworkRing({ artworkUrl, seek, duration, seekable, onOp
12
12
  seekable: boolean;
13
13
  /** Makes the artwork itself a button, for opening something (the library). */
14
14
  onOpen?: (() => void) | undefined;
15
- /** The ring's diameter in px. Default 168. */
15
+ /** The ring's diameter in px, when there is room for it; it shrinks in a column that is shorter. Default 168. */
16
16
  size?: number;
17
+ /** The most it grows to, in px, when the column has more room than `size` (a card stretched beside other cards). Default: as far as the width allows. */
18
+ maxSize?: number;
17
19
  }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -6,11 +6,13 @@ import { Icon } from '../icon.js';
6
6
  import { Cover } from '../layout/cover.js';
7
7
  /** The ring's diameter when none is given. */
8
8
  const DEFAULT_SIZE = 168;
9
+ /** The smallest it shrinks to, when the space is short. */
10
+ const MIN_SIZE = 72;
9
11
  /** The player's artwork in a circle, like the one in the bar but larger: an inner outline around
10
12
  * it, and an outer ring that shows how far along playback is. With `onSeek` the ring is a slider:
11
13
  * drag around it, or use the arrow keys. Not exported from the package: `MediaPlayerColumn` and
12
14
  * `MediaPlayerFull` draw it. */
13
- export function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size: SIZE = DEFAULT_SIZE, }) {
15
+ export function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size: SIZE = DEFAULT_SIZE, maxSize = Infinity, }) {
14
16
  const ring = useRef(null);
15
17
  /** A drag in progress: where round the ring the pointer was last, and how far along it has taken
16
18
  * the position, which may run past either end while the pointer carries on (see `follow`). */
@@ -60,56 +62,82 @@ export function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size
60
62
  event.preventDefault();
61
63
  seek.commit(Math.min(total, Math.max(0, Math.round((shown ?? 0) + step))));
62
64
  };
63
- return (_jsxs(Box, { position: "relative", width: SIZE, height: SIZE, mx: "auto", css: { flex: 'none' }, children: [_jsx(Box, { ref: ring, position: "absolute", cursor: total !== undefined ? 'pointer' : 'default', ...(total !== undefined
64
- ? {
65
- role: 'slider',
66
- tabIndex: 0,
67
- 'aria-label': 'Position',
68
- 'aria-valuemin': 0,
69
- 'aria-valuemax': Math.round(total ?? 0),
70
- 'aria-valuenow': Math.round(shown ?? 0),
71
- onKeyDown,
72
- onPointerDown: (event) => {
73
- const box = event.currentTarget.getBoundingClientRect();
74
- const distance = Math.hypot(event.clientX - (box.left + box.width / 2), event.clientY - (box.top + box.height / 2));
75
- // Only the ring itself seeks, not the artwork inside it.
76
- if (distance < box.width * 0.4) {
77
- return;
78
- }
79
- // Where it was pressed is where the position starts.
80
- const turn = turnAt(event) ?? 0;
81
- drag.current = { turn, along: turn };
82
- event.currentTarget.setPointerCapture?.(event.pointerId);
83
- seek.preview(Math.round(turn * total));
84
- },
85
- onPointerMove: (event) => {
86
- if (drag.current) {
87
- seek.preview(follow(event) ?? 0);
88
- }
89
- },
90
- onPointerUp: (event) => {
91
- if (drag.current) {
92
- const next = follow(event);
65
+ return (_jsx(Box, { css: {
66
+ flex: `1 1 ${SIZE}px`,
67
+ minHeight: MIN_SIZE,
68
+ display: 'flex',
69
+ alignItems: 'center',
70
+ justifyContent: 'center',
71
+ containerType: 'size',
72
+ }, children: _jsxs(Box, { position: "relative", css: {
73
+ aspectRatio: '1',
74
+ // A browser that does not know container units keeps the first, fixed size.
75
+ width: [
76
+ `${SIZE}px`,
77
+ `min(100cqw, 100cqh, ${Math.max(maxSize, SIZE)}px)`,
78
+ ],
79
+ flex: 'none',
80
+ }, children: [_jsx(Box, { ref: ring, position: "absolute", cursor: total !== undefined ? 'pointer' : 'default', ...(total !== undefined
81
+ ? {
82
+ role: 'slider',
83
+ tabIndex: 0,
84
+ 'aria-label': 'Position',
85
+ 'aria-valuemin': 0,
86
+ 'aria-valuemax': Math.round(total ?? 0),
87
+ 'aria-valuenow': Math.round(shown ?? 0),
88
+ onKeyDown,
89
+ onPointerDown: (event) => {
90
+ const box = event.currentTarget.getBoundingClientRect();
91
+ const distance = Math.hypot(event.clientX - (box.left + box.width / 2), event.clientY - (box.top + box.height / 2));
92
+ // Only the ring itself seeks, not the artwork inside it.
93
+ if (distance < box.width * 0.4) {
94
+ return;
95
+ }
96
+ // Where it was pressed is where the position starts.
97
+ const turn = turnAt(event) ?? 0;
98
+ drag.current = { turn, along: turn };
99
+ event.currentTarget.setPointerCapture?.(event.pointerId);
100
+ seek.preview(Math.round(turn * total));
101
+ },
102
+ onPointerMove: (event) => {
103
+ if (drag.current) {
104
+ seek.preview(follow(event) ?? 0);
105
+ }
106
+ },
107
+ onPointerUp: (event) => {
108
+ if (drag.current) {
109
+ const next = follow(event);
110
+ drag.current = null;
111
+ if (next !== undefined) {
112
+ seek.commit(next);
113
+ }
114
+ }
115
+ },
116
+ // A touch the browser took or lost never ends with a release: let go of the position.
117
+ onPointerCancel: () => {
93
118
  drag.current = null;
94
- if (next !== undefined) {
95
- seek.commit(next);
119
+ seek.cancel();
120
+ },
121
+ onLostPointerCapture: () => {
122
+ if (drag.current) {
123
+ drag.current = null;
124
+ seek.cancel();
125
+ }
126
+ },
127
+ }
128
+ : {}), css: { inset: 0, touchAction: 'none' }, children: _jsxs("svg", { viewBox: "0 0 100 100", width: "100%", height: "100%", "aria-hidden": "true", children: [_jsx("circle", { cx: "50", cy: "50", r: "47", fill: "none", strokeWidth: "3", css: ({ palette }) => ({ stroke: palette.border }) }), fraction !== undefined ? (_jsx("circle", { cx: "50", cy: "50", r: "47", fill: "none", strokeWidth: "3", strokeLinecap: "round", pathLength: 100, strokeDasharray: `${fraction * 100} 100`, transform: "rotate(-90 50 50)", css: ({ palette }) => ({ stroke: palette.accent }) })) : null] }) }), _jsx(Box, { position: "absolute", radius: "full", border: true, css: { inset: '9%' } }), _jsx(Flex, { center: true, position: "absolute", radius: "full", overflow: "hidden", background: "surfaceRaised", color: "textMuted", cursor: onOpen ? 'pointer' : 'inherit', css: { inset: '14%' }, ...(onOpen
129
+ ? {
130
+ role: 'button',
131
+ tabIndex: 0,
132
+ 'aria-label': 'Open media browser',
133
+ title: 'Open media browser',
134
+ onClick: onOpen,
135
+ onKeyDown: (event) => {
136
+ if (event.key === 'Enter' || event.key === ' ') {
137
+ event.preventDefault();
138
+ onOpen();
96
139
  }
97
- }
98
- },
99
- }
100
- : {}), css: { inset: 0, touchAction: 'none' }, children: _jsxs("svg", { viewBox: "0 0 100 100", width: SIZE, height: SIZE, "aria-hidden": "true", children: [_jsx("circle", { cx: "50", cy: "50", r: "47", fill: "none", strokeWidth: "3", css: ({ palette }) => ({ stroke: palette.border }) }), fraction !== undefined ? (_jsx("circle", { cx: "50", cy: "50", r: "47", fill: "none", strokeWidth: "3", strokeLinecap: "round", pathLength: 100, strokeDasharray: `${fraction * 100} 100`, transform: "rotate(-90 50 50)", css: ({ palette }) => ({ stroke: palette.accent }) })) : null] }) }), _jsx(Box, { position: "absolute", radius: "full", border: true, css: { inset: '9%' } }), _jsx(Flex, { center: true, position: "absolute", radius: "full", overflow: "hidden", background: "surfaceRaised", color: "textMuted", cursor: onOpen ? 'pointer' : 'inherit', css: { inset: '14%' }, ...(onOpen
101
- ? {
102
- role: 'button',
103
- tabIndex: 0,
104
- 'aria-label': 'Open media browser',
105
- title: 'Open media browser',
106
- onClick: onOpen,
107
- onKeyDown: (event) => {
108
- if (event.key === 'Enter' || event.key === ' ') {
109
- event.preventDefault();
110
- onOpen();
111
- }
112
- },
113
- }
114
- : {}), children: artworkUrl ? _jsx(Cover, { src: artworkUrl }) : _jsx(Icon, { name: "lu:music", size: 40 }) })] }));
140
+ },
141
+ }
142
+ : {}), children: artworkUrl ? _jsx(Cover, { src: artworkUrl }) : _jsx(Icon, { name: "lu:music", size: 40 }) })] }) }));
115
143
  }
@@ -19,6 +19,8 @@ export interface LightTileProps {
19
19
  history?: LogbookEntry[];
20
20
  /** Swatches shown when the palette button is opened. Default: four white temperatures. Each takes `kelvin` (color-temp lights) and/or `hs` (color lights) and is skipped if the light can't apply it. */
21
21
  colors?: LightColorPreset[];
22
+ /** Holding the tile opens a drawer with the full controls, the color bars, the energy chart and the recent activity. `false` leaves the tile as it is without it: a toggle that dims by dragging, with the color swatches if the light has color, and no drawer built, which is lighter on a small display. Default `true`. */
23
+ drawer?: boolean;
22
24
  }
23
25
  /** Toggle, drag-to-dim (if dimmable) and a color overlay + drawer picker (if color capable). */
24
- export declare function LightTile({ entity, name, icon, energy, history, colors, }: LightTileProps): import("@emotion/react/jsx-runtime").JSX.Element;
26
+ export declare function LightTile({ entity, name, icon, energy, history, colors, drawer, }: LightTileProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -14,7 +14,7 @@ const kelvinGradient = (min, max) => `linear-gradient(to right, ${[0, 0.25, 0.5,
14
14
  .map((f) => rgbCss(kelvinToRgb(min + f * (max - min))))
15
15
  .join(', ')})`;
16
16
  /** Toggle, drag-to-dim (if dimmable) and a color overlay + drawer picker (if color capable). */
17
- export function LightTile({ entity, name, icon, energy, history, colors = DEFAULT_COLOR_PRESETS, }) {
17
+ export function LightTile({ entity, name, icon, energy, history, colors = DEFAULT_COLOR_PRESETS, drawer = true, }) {
18
18
  const handle = useEntityHandle('light', entity);
19
19
  const [picking, setPicking] = useState(false);
20
20
  const [colorOpen, setColorOpen] = useState(false);
@@ -36,20 +36,24 @@ export function LightTile({ entity, name, icon, energy, history, colors = DEFAUL
36
36
  return data ? [{ preset, data }] : [];
37
37
  });
38
38
  const showPicker = color && status === 'ready';
39
- return (_jsx(Tile, { label: name ?? light?.name ?? fallbackName(entity), icon: icon ?? (on ? 'lu:lightbulb' : 'lu:lightbulb-off'), kind: "Light", status: status, active: on, secondary: on ? (dimmable && percent !== undefined ? `${percent}%` : undefined) : 'Off', onPress: () => void handle.command('toggle'), detail: _jsx(LightDetailBody, { on: on, percent: dimmable ? percent : undefined, onToggle: () => void handle.command('toggle'), ...(dimmable
40
- ? {
41
- onBrightness: (next) => void handle.command('setBrightness', { brightness: next / 100 }),
42
- }
43
- : {}), colorSection: showPicker ? (_jsx(LightColorSection, { light: light, open: colorOpen, onToggle: () => setColorOpen((open) => !open), supportsTemp: supportsTemp, supportsHs: supportsHs, onKelvin: (kelvin) => void handle.command('setColorTemperature', { kelvin }), onHue: (hue) => void handle.command('setColor', {
44
- hue,
45
- saturation: currentHs(light)[1] || 100,
46
- }), onPicked: (hex) => {
47
- const picked = hexToRgb(hex);
48
- if (picked) {
49
- const [hue, saturation] = rgbToHs(picked);
50
- void handle.command('setColor', { hue, saturation });
51
- }
52
- } })) : null }), ...(energy ? { energy } : {}), ...(history ? { history } : {}), ...(typeof entity === 'string' ? { logbook: entity } : {}), ...(dimmable
39
+ return (_jsx(Tile, { label: name ?? light?.name ?? fallbackName(entity), icon: icon ?? (on ? 'lu:lightbulb' : 'lu:lightbulb-off'), kind: "Light", status: status, active: on, secondary: on ? (dimmable && percent !== undefined ? `${percent}%` : undefined) : 'Off', onPress: () => void handle.command('toggle'), ...(drawer
40
+ ? {
41
+ detail: (_jsx(LightDetailBody, { on: on, percent: dimmable ? percent : undefined, onToggle: () => void handle.command('toggle'), ...(dimmable
42
+ ? {
43
+ onBrightness: (next) => void handle.command('setBrightness', { brightness: next / 100 }),
44
+ }
45
+ : {}), colorSection: showPicker ? (_jsx(LightColorSection, { light: light, open: colorOpen, onToggle: () => setColorOpen((open) => !open), supportsTemp: supportsTemp, supportsHs: supportsHs, onKelvin: (kelvin) => void handle.command('setColorTemperature', { kelvin }), onHue: (hue) => void handle.command('setColor', {
46
+ hue,
47
+ saturation: currentHs(light)[1] || 100,
48
+ }), onPicked: (hex) => {
49
+ const picked = hexToRgb(hex);
50
+ if (picked) {
51
+ const [hue, saturation] = rgbToHs(picked);
52
+ void handle.command('setColor', { hue, saturation });
53
+ }
54
+ } })) : null })),
55
+ }
56
+ : {}), ...(drawer && energy ? { energy } : {}), ...(drawer && history ? { history } : {}), ...(drawer && typeof entity === 'string' ? { logbook: entity } : {}), ...(dimmable
53
57
  ? {
54
58
  fill: on ? (brightness ?? 1) : 0,
55
59
  onFillChange: (fill) => void handle.command('setBrightness', { brightness: fill }),
@@ -69,11 +73,16 @@ export function LightTile({ entity, name, icon, energy, history, colors = DEFAUL
69
73
  : handle.command('setColor', data.args));
70
74
  setPicking(false);
71
75
  },
72
- })), customLabel: "Custom color", onCustom: () => {
73
- setPicking(false);
74
- setColorOpen(true);
75
- openDetail();
76
- } })),
76
+ })), ...(drawer
77
+ ? {
78
+ customLabel: 'Custom color',
79
+ onCustom: () => {
80
+ setPicking(false);
81
+ setColorOpen(true);
82
+ openDetail();
83
+ },
84
+ }
85
+ : {}) })),
77
86
  }
78
87
  : {}), trailing: showPicker ? (_jsx(IconButton, { icon: picking ? 'lu:x' : 'lu:palette', label: picking ? 'Close colors' : 'Color', active: false, onClick: () => setPicking((open) => !open), size: 16, ...(on && paletteColor && !picking ? { color: paletteColor } : {}) })) : null }));
79
88
  }
@@ -7,9 +7,11 @@ export interface MediaBrowserProps {
7
7
  onPlay?: (item: BrowseItem) => void;
8
8
  /** How items are laid out. `list` is rows, compact enough for a narrow drawer. `theater` is a single row of large cards that scrolls sideways, for a wide space. `auto` is `theater` inside an expanded drawer and `list` anywhere else. Default `list`. */
9
9
  layout?: 'list' | 'theater' | 'auto';
10
+ /** How the search is offered, for a library that can be searched. `bar` is a field above the list. `icon` is a search icon at the right end of the row of tabs (or of the heading), after a thin line, which becomes the field when pressed, in that same row, so search takes no room of its own; its close button puts the tabs back. Default `bar`. */
11
+ search?: 'bar' | 'icon';
10
12
  }
11
13
  /** The library of a media player, in the app's own style: shelves, albums, playlists and the rest
12
14
  * as rows with artwork. Tap a folder to open it, a track or station to play it, or the play button
13
15
  * on an album, playlist or artist to play all of it. A search box appears when the library can be
14
16
  * searched. */
15
- export declare function MediaBrowser({ entity, onPlay, layout }: MediaBrowserProps): import("@emotion/react/jsx-runtime").JSX.Element;
17
+ export declare function MediaBrowser({ entity, onPlay, layout, search: searchMode, }: MediaBrowserProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -1,5 +1,7 @@
1
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
+ import { AnimatePresence, motion } from 'motion/react';
4
+ import { useState } from 'react';
3
5
  import { Icon } from '../icon.js';
4
6
  import { useEntityHandle } from '../hooks.js';
5
7
  import { useDetail } from '../layout/detail-provider.js';
@@ -12,6 +14,8 @@ import { Reveal } from '../layout/reveal.js';
12
14
  import { Skeleton } from '../layout/skeleton.js';
13
15
  import { IconButton } from '../layout/tile.js';
14
16
  import { useMediaBrowser } from '../use-media-browser.js';
17
+ /** The height of the search field, and of the row of tabs it replaces. */
18
+ const SEARCH_HEIGHT = 36;
15
19
  const KIND_ICONS = {
16
20
  folder: 'lu:folder',
17
21
  artist: 'lu:mic-vocal',
@@ -25,11 +29,13 @@ const KIND_ICONS = {
25
29
  * as rows with artwork. Tap a folder to open it, a track or station to play it, or the play button
26
30
  * on an album, playlist or artist to play all of it. A search box appears when the library can be
27
31
  * searched. */
28
- export function MediaBrowser({ entity, onPlay, layout = 'list' }) {
32
+ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMode = 'bar', }) {
29
33
  const handle = useEntityHandle('mediaPlayer', entity);
30
34
  const { detail } = useDetail();
31
35
  const theater = layout === 'theater' || (layout === 'auto' && detail?.expanded === true);
32
36
  const browser = useMediaBrowser(entity);
37
+ // `icon`: the field is open (or has something in it); otherwise only the icon is there.
38
+ const [searchOpen, setSearchOpen] = useState(false);
33
39
  const player = handle.entity;
34
40
  // A player with a queue can be told to add to it, play next and shuffle; one without can only play.
35
41
  const queueable = player?.capabilities.queue === true;
@@ -61,21 +67,41 @@ export function MediaBrowser({ entity, onPlay, layout = 'list' }) {
61
67
  * no room for words they are just their icons. */
62
68
  const opened = browser.inside;
63
69
  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] }));
65
- const search = player?.capabilities.search ? (_jsxs(Flex, { align: "center", gap: 2, background: "surfaceRaised", radius: "full", px: 3, height: 36, color: "textMuted",
70
+ const canSearch = player?.capabilities.search === true;
71
+ const onDemand = searchMode === 'icon' && canSearch;
72
+ const fieldShown = onDemand && (searchOpen || browser.query !== '');
73
+ const closeSearch = () => {
74
+ browser.search('');
75
+ setSearchOpen(false);
76
+ };
77
+ /** `icon`: a thin line and the search icon, fixed at the end of the row. */
78
+ const searchTrigger = onDemand ? (_jsxs(Flex, { align: "center", gap: 2, css: { flexShrink: 0 }, children: [_jsx(Box, { width: 1, height: 20, background: "border" }), _jsx(PlainButton, { "aria-label": "Search", title: "Search", onClick: () => setSearchOpen(true), center: true, color: "textMuted", width: SEARCH_HEIGHT, height: SEARCH_HEIGHT, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:search", size: 18 }) })] })) : null;
79
+ 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, searchTrigger] }));
80
+ const search = canSearch ? (_jsxs(Flex, { align: "center", gap: 2, background: "surfaceRaised", radius: "full", px: 3, height: SEARCH_HEIGHT, color: "textMuted",
66
81
  // Fixed height: when the list takes all the room, the search keeps its size and the list scrolls.
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;
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}`))) }))] }));
82
+ 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), ...(onDemand
83
+ ? {
84
+ autoFocus: true,
85
+ onKeyDown: (event) => {
86
+ if (event.key === 'Escape') {
87
+ closeSearch();
88
+ }
89
+ },
90
+ }
91
+ : {}), css: { background: 'none', outline: 'none' } }), onDemand ? (_jsx(PlainButton, { "aria-label": "Close search", title: "Close search", onClick: closeSearch, center: true, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : 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;
92
+ /** The categories (and, with `search='icon'`, the search icon at the end). */
93
+ const tabsRow = browser.tabs ? (_jsxs(Flex, { align: "center", gap: 3, ...(onDemand ? { minHeight: SEARCH_HEIGHT } : {}), children: [_jsx(Flex, { direction: "column", grow: 1, minWidth: 0, children: _jsx(ChipRow, { tabs: true, options: browser.tabs.map((item) =>
94
+ // Inside a shelf the selected tab becomes the way back, so nothing is added to the
95
+ // layout and nothing below it moves.
96
+ item.id === browser.activeTab && inside
97
+ ? {
98
+ value: item.id,
99
+ label: browser.title ?? item.title,
100
+ icon: 'lu:arrow-left',
101
+ ariaLabel: 'Back',
102
+ }
103
+ : { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) }) }), actionsHere, searchTrigger] })) : null;
104
+ return (_jsxs(Flex, { direction: "column", gap: 3, minHeight: 0, position: "relative", children: [onDemand ? (_jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(motion.div, { initial: { opacity: 0, x: fieldShown ? 28 : 0 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: fieldShown ? 0 : 28 }, transition: { duration: 0.18, ease: 'easeOut' }, children: fieldShown ? search : browser.tabs === undefined ? header : tabsRow }, fieldShown ? 'field' : 'row') })) : (_jsxs(_Fragment, { children: [browser.tabs === undefined ? header : null, search, tabsRow] })), browser.error ? (_jsx(Message, { theater: theater, children: _jsx(Typography, { as: "p", variant: "body", color: "danger", role: "alert", m: 0, children: browser.error }) })) : browser.loading ? (_jsx(BrowseSkeleton, { theater: theater })) : browser.items.length === 0 ? (_jsx(Message, { theater: theater, children: _jsx(Typography, { as: "p", variant: "body", color: "textMuted", m: 0, children: browser.searching ? 'Nothing found.' : 'Nothing here.' }) })) : theater ? (_jsx(FadeScroll, { as: "ul", gap: 4, m: 0, p: 0, pb: 2, css: { ...THEATER_STAGE, listStyle: 'none', scrollSnapType: 'x proximity' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none', scrollSnapAlign: 'start' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseCard, { item: item, queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) })) : (_jsx(Flex, { direction: "column", gap: 1, as: "ul", m: 0, p: 0, minHeight: 0, css: { listStyle: 'none', overflowY: 'auto' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseRow, { item: item, queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onNext: () => playNext(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) }))] }));
79
105
  }
80
106
  /** What shows where the list will be while it loads: placeholders shaped like what is coming (cards in
81
107
  * the theater layout, rows in the list), in the room the real thing takes, so nothing moves. */
@@ -9,6 +9,14 @@ export interface MediaPlayerBarProps {
9
9
  name?: string;
10
10
  /** Content for the media browser shown with the player in the drawer. Holding the card or pressing the artwork opens the drawer; the browse button opens it expanded. By default a `MediaBrowser` over the player's own library, shown only for a ref whose player has one; pass your own content to replace it, or `false` for no browse button. */
11
11
  browse?: ReactNode | false;
12
+ /** `false` builds no drawer: holding the card, pressing the artwork and the browse button then do nothing, which is lighter on a small display. Default `true`. See `onOpen` to send them somewhere else instead. */
13
+ drawer?: boolean;
14
+ /** Called instead of opening the drawer when the card is held, the artwork pressed or the browse button pressed: for a dashboard with a page of its own for the player (a `MediaPlayerFull`), `onOpen={() => navigate('/bathroom/music')}`. Given, no drawer is built. The browse button stays unless `browse` is `false`. */
15
+ onOpen?: () => void;
16
+ /** The library and the queue each open as a full-size overlay of their own, from the browse button and a new queue button, instead of one drawer with the whole player in it: for a small display, where the card itself is the player. The library overlay is a `MediaBrowser` as a list (or your `browse` content), the queue overlay a `MediaQueue`; each button is left out when the player has none, or for `browse={false}`. Holding the card and pressing the artwork then do nothing, unless `onOpen` is given. Default `false`. */
17
+ overlays?: boolean;
18
+ /** `1` puts the controls beside the track, in one pill; `2` puts them on a second row under it, for a narrow space (a small wall display). Default `'auto'`: one row, and two once the bar is narrower than 560 px wide. */
19
+ rows?: 1 | 2 | 'auto';
12
20
  }
13
21
  /** Now playing with previous / play-pause / next and a volume control, for a media player entity. */
14
- export declare function MediaPlayerBar({ entity, name, browse }: MediaPlayerBarProps): import("@emotion/react/jsx-runtime").JSX.Element;
22
+ export declare function MediaPlayerBar({ entity, name, browse, drawer, onOpen, overlays, rows, }: MediaPlayerBarProps): import("@emotion/react/jsx-runtime").JSX.Element;