@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/debug.d.ts +27 -0
- package/dist/debug.js +95 -0
- package/dist/entities/artwork-ring.d.ts +4 -2
- package/dist/entities/artwork-ring.js +79 -51
- package/dist/entities/light-tile.d.ts +3 -1
- package/dist/entities/light-tile.js +29 -20
- package/dist/entities/media-browser.d.ts +3 -1
- package/dist/entities/media-browser.js +41 -15
- package/dist/entities/media-player-bar.d.ts +9 -1
- package/dist/entities/media-player-bar.js +122 -12
- package/dist/entities/media-player-column.d.ts +5 -2
- package/dist/entities/media-player-column.js +33 -5
- package/dist/entities/media-player-full.d.ts +12 -2
- package/dist/entities/media-player-full.js +45 -3
- package/dist/entities/media-seek.d.ts +4 -1
- package/dist/entities/media-seek.js +22 -3
- package/dist/entities/nav-dock.js +3 -3
- package/dist/entities/now-playing.d.ts +3 -1
- package/dist/entities/now-playing.js +8 -3
- package/dist/entities/seek-line.js +12 -0
- package/dist/entities/top-bar.d.ts +21 -0
- package/dist/entities/top-bar.js +19 -10
- package/dist/gallery/gallery.js +18 -3
- package/dist/gallery/props-data.js +187 -3
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/layout/animated-outlet.d.ts +14 -0
- package/dist/layout/animated-outlet.js +44 -0
- package/dist/layout/board.d.ts +26 -0
- package/dist/layout/board.js +85 -0
- package/dist/layout/debug-menu.d.ts +11 -0
- package/dist/layout/debug-menu.js +99 -0
- package/dist/layout/detail-provider.d.ts +4 -2
- package/dist/layout/detail-provider.js +2 -2
- package/dist/layout/drawer-controls.js +36 -8
- package/dist/layout/entity-drawer.js +1 -1
- package/dist/layout/grid-overlay.d.ts +6 -0
- package/dist/layout/grid-overlay.js +143 -0
- package/dist/layout/hold-progress.d.ts +8 -2
- package/dist/layout/hold-progress.js +20 -4
- package/dist/layout/page.d.ts +7 -1
- package/dist/layout/page.js +43 -6
- package/dist/layout/room-header.js +1 -1
- package/dist/layout/tile.js +14 -10
- package/dist/layout/top-row.d.ts +9 -0
- package/dist/layout/top-row.js +10 -0
- package/dist/layout/use-drawer.d.ts +3 -2
- package/dist/layout/use-drawer.js +5 -3
- package/dist/provider.d.ts +15 -4
- package/dist/provider.js +144 -13
- package/dist/theme/global-styles.js +3 -0
- package/dist/theme/grid.d.ts +30 -0
- package/dist/theme/grid.js +38 -0
- package/dist/theme/index.js +2 -1
- package/dist/theme/schedule.d.ts +33 -0
- package/dist/theme/schedule.js +44 -0
- package/dist/theme/tokens.d.ts +2 -0
- package/package.json +2 -2
package/dist/debug.d.ts
ADDED
|
@@ -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 (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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'),
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
})),
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
65
|
-
const
|
|
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),
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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;
|