@hashsome/ui 0.4.0 → 0.5.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/entities/artwork-ring.js +11 -0
- package/dist/entities/light-tile.d.ts +3 -1
- package/dist/entities/light-tile.js +29 -20
- package/dist/entities/media-player-bar.d.ts +9 -1
- package/dist/entities/media-player-bar.js +122 -12
- 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/seek-line.js +12 -0
- package/dist/entities/top-bar.d.ts +21 -0
- package/dist/entities/top-bar.js +13 -8
- package/dist/gallery/gallery.js +16 -3
- package/dist/gallery/props-data.js +86 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/layout/animated-outlet.d.ts +14 -0
- package/dist/layout/animated-outlet.js +44 -0
- package/dist/layout/detail-provider.d.ts +4 -2
- package/dist/layout/detail-provider.js +2 -2
- package/dist/layout/drawer-controls.js +18 -2
- package/dist/layout/entity-drawer.js +1 -1
- package/dist/layout/hold-progress.d.ts +8 -2
- package/dist/layout/hold-progress.js +20 -4
- package/dist/layout/tile.js +6 -10
- package/dist/layout/use-drawer.d.ts +3 -2
- package/dist/layout/use-drawer.js +5 -3
- package/dist/provider.d.ts +10 -3
- package/dist/provider.js +124 -11
- package/dist/theme/global-styles.js +3 -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
|
@@ -96,6 +96,17 @@ export function ArtworkRing({ artworkUrl, seek, duration, seekable, onOpen, size
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
},
|
|
99
|
+
// A touch the browser took or lost never ends with a release: let go of the position.
|
|
100
|
+
onPointerCancel: () => {
|
|
101
|
+
drag.current = null;
|
|
102
|
+
seek.cancel();
|
|
103
|
+
},
|
|
104
|
+
onLostPointerCapture: () => {
|
|
105
|
+
if (drag.current) {
|
|
106
|
+
drag.current = null;
|
|
107
|
+
seek.cancel();
|
|
108
|
+
}
|
|
109
|
+
},
|
|
99
110
|
}
|
|
100
111
|
: {}), 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
112
|
? {
|
|
@@ -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
|
}
|
|
@@ -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;
|
|
@@ -20,12 +20,15 @@ import { MediaQueue } from './media-queue.js';
|
|
|
20
20
|
import { formatDuration, useMediaPosition } from './media-progress.js';
|
|
21
21
|
import { useSeekHold } from './media-seek.js';
|
|
22
22
|
import { SeekLine } from './seek-line.js';
|
|
23
|
+
/** Narrower than this, a bar with `rows="auto"` is two rows. */
|
|
24
|
+
const TWO_ROWS_BELOW = 560;
|
|
23
25
|
/** Glyph size inside the player's 44px circles — smaller than an icon's default so there is breathing room around it. */
|
|
24
26
|
const MEDIA_GLYPH = 18;
|
|
25
27
|
/** The ring around the artwork (the size of an icon circle): a button when there is somewhere to
|
|
26
|
-
* go, otherwise just the ring.
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
* go, otherwise just the ring. `fraction` (0 to 1) fills it with how far along playback is, from the
|
|
29
|
+
* top, like the large player's ring. */
|
|
30
|
+
function Ring({ onOpen, fraction, children, }) {
|
|
31
|
+
return (_jsxs(Flex, { ...(onOpen
|
|
29
32
|
? {
|
|
30
33
|
as: 'button',
|
|
31
34
|
type: 'button',
|
|
@@ -34,23 +37,102 @@ function Ring({ onOpen, children }) {
|
|
|
34
37
|
onClick: onOpen,
|
|
35
38
|
cursor: 'pointer',
|
|
36
39
|
}
|
|
37
|
-
: { as: 'span' }), center: true, position: "relative", p: 0,
|
|
40
|
+
: { as: 'span' }), "data-part": "ring", center: true, position: "relative", p: 0, radius: "full", css: ({ density }) => ({
|
|
38
41
|
width: density.iconCircle,
|
|
39
42
|
height: density.iconCircle,
|
|
40
43
|
flex: 'none',
|
|
41
44
|
background: 'none',
|
|
42
|
-
}), children:
|
|
45
|
+
}), children: [_jsxs("svg", { viewBox: "0 0 100 100", "aria-hidden": "true", css: {
|
|
46
|
+
position: 'absolute',
|
|
47
|
+
inset: 0,
|
|
48
|
+
width: '100%',
|
|
49
|
+
height: '100%',
|
|
50
|
+
pointerEvents: 'none',
|
|
51
|
+
}, children: [_jsx("circle", { cx: "50", cy: "50", r: "47.5", fill: "none", strokeWidth: "4", css: ({ palette }) => ({ stroke: palette.border }) }), fraction !== undefined ? (_jsx("circle", { "data-part": "progress", cx: "50", cy: "50", r: "47.5", fill: "none", strokeWidth: "4", strokeLinecap: "round", pathLength: 100, strokeDasharray: `${fraction * 100} 100`, transform: "rotate(-90 50 50)", css: ({ palette }) => ({ stroke: palette.accent }) })) : null] }), children] }));
|
|
43
52
|
}
|
|
44
53
|
/** The same spring the swatch rows pop in with, so the volume overlay opens like they do. */
|
|
45
54
|
const VOLUME_SPRING = { type: 'spring', stiffness: 520, damping: 26 };
|
|
55
|
+
/** What the pill is like in two rows: the artwork, "title · artist" and the time on top; under them
|
|
56
|
+
* the buttons share the whole width, all of one size, and while the volume slider is open it has the
|
|
57
|
+
* row to itself, with only the button that closes it. The bar is as tall as two regular tiles and
|
|
58
|
+
* the gap between them (a tile is its icon circle and a spacing unit), so it sits in a grid of
|
|
59
|
+
* tiles. A line break between the rows comes from an empty item that fills a line of its own. */
|
|
60
|
+
const stacked = ({ radius, spacing, density, }) => ({
|
|
61
|
+
display: 'flex',
|
|
62
|
+
flexWrap: 'wrap',
|
|
63
|
+
alignItems: 'center',
|
|
64
|
+
alignContent: 'center',
|
|
65
|
+
columnGap: spacing(2.5),
|
|
66
|
+
rowGap: 0,
|
|
67
|
+
minHeight: 2 * (density.iconCircle + density.space / 3) + density.space,
|
|
68
|
+
paddingTop: spacing(2.5),
|
|
69
|
+
paddingBottom: spacing(2.5),
|
|
70
|
+
borderRadius: radius.card,
|
|
71
|
+
'&::before': { content: '""', order: 1, flexBasis: '100%', height: 0 },
|
|
72
|
+
'& > [data-part="track"]': {
|
|
73
|
+
flex: '1 1 0',
|
|
74
|
+
minWidth: 0,
|
|
75
|
+
flexDirection: 'row',
|
|
76
|
+
alignItems: 'baseline',
|
|
77
|
+
gap: spacing(1.5),
|
|
78
|
+
// The title and the artist share the line and shorten, with an ellipsis, before they run into
|
|
79
|
+
// the time; the artist follows the title after a dot.
|
|
80
|
+
'& > :nth-child(1)': {
|
|
81
|
+
flex: '0 1 auto',
|
|
82
|
+
minWidth: 0,
|
|
83
|
+
// The title is what gives way, not the shuffle button beside it.
|
|
84
|
+
'& > :first-child': { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' },
|
|
85
|
+
},
|
|
86
|
+
'& > :nth-child(2)': {
|
|
87
|
+
flex: '0 1 auto',
|
|
88
|
+
minWidth: 0,
|
|
89
|
+
overflow: 'hidden',
|
|
90
|
+
'&::before': { content: '"·"', marginRight: spacing(1.5) },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
'& [data-part="tools"], & [data-part="transport"]': { display: 'contents' },
|
|
94
|
+
'& [data-part="tools"] > button, & [data-part="transport"] > button': {
|
|
95
|
+
order: 2,
|
|
96
|
+
flex: '1 1 0',
|
|
97
|
+
minWidth: 0,
|
|
98
|
+
width: 'auto',
|
|
99
|
+
height: 34,
|
|
100
|
+
marginTop: spacing(2.5),
|
|
101
|
+
},
|
|
102
|
+
'& [data-part="transport"] > button': { order: 4 },
|
|
103
|
+
'& > [data-part="volume"]': {
|
|
104
|
+
order: 3,
|
|
105
|
+
flex: '1 1 0',
|
|
106
|
+
minWidth: 0,
|
|
107
|
+
marginTop: spacing(2.5),
|
|
108
|
+
},
|
|
109
|
+
'&[data-volume-open="true"]': {
|
|
110
|
+
'& > [data-part="track"], & > [data-part="time"]': { display: 'flex' },
|
|
111
|
+
// The slider has the row: only the button that closes it stays, a circle beside it.
|
|
112
|
+
'& [data-part="tools"] > button:nth-of-type(n + 2), & [data-part="transport"] > button': {
|
|
113
|
+
display: 'none',
|
|
114
|
+
},
|
|
115
|
+
'& [data-part="tools"] > button:first-of-type': { flex: 'none', width: 34 },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
46
118
|
/** The bar itself. Holding it (anywhere but on a button or slider) opens the drawer, with a thin
|
|
47
119
|
* line along the top that fills while the press is timed, like a tile's. */
|
|
48
|
-
function HoldCard({ open, status, children, }) {
|
|
120
|
+
function HoldCard({ open, status, rows, volumeOpen, children, }) {
|
|
49
121
|
const hold = useHold(() => open?.(), open !== undefined);
|
|
50
|
-
|
|
122
|
+
const card = (_jsxs(Flex, { position: "relative", align: "center", grow: 1, minWidth: 0, gap: 3, pl: 2.25, pr: 3, background: "surface", radius: "full", overflow: "hidden", "data-status": status, "data-rows": rows === 2 ? 2 : undefined, "data-volume-open": volumeOpen, css: (theme) => ({
|
|
123
|
+
minHeight: theme.density.tileHeight,
|
|
124
|
+
// In one row the volume slider takes the track's place; in two it has its own row.
|
|
125
|
+
'&[data-volume-open="true"] > [data-part="track"], &[data-volume-open="true"] > [data-part="time"]': { display: 'none' },
|
|
126
|
+
...(rows === 2 ? stacked(theme) : {}),
|
|
127
|
+
...(rows === 'auto'
|
|
128
|
+
? { [`@container (max-width: ${TWO_ROWS_BELOW - 1}px)`]: stacked(theme) }
|
|
129
|
+
: {}),
|
|
130
|
+
}), ...hold.handlers, children: [children, hold.holding ? _jsx(HoldProgress, {}) : null] }));
|
|
131
|
+
// `auto` asks the width the bar is given, so its parent is the thing measured.
|
|
132
|
+
return rows === 'auto' ? (_jsx(Flex, { width: "100%", css: { containerType: 'inline-size' }, children: card })) : (card);
|
|
51
133
|
}
|
|
52
134
|
/** Now playing with previous / play-pause / next and a volume control, for a media player entity. */
|
|
53
|
-
export function MediaPlayerBar({ entity, name, browse }) {
|
|
135
|
+
export function MediaPlayerBar({ entity, name, browse, drawer = true, onOpen, overlays = false, rows = 'auto', }) {
|
|
54
136
|
const handle = useEntityHandle('mediaPlayer', entity);
|
|
55
137
|
const player = handle.entity;
|
|
56
138
|
const status = handle.status;
|
|
@@ -66,14 +148,42 @@ export function MediaPlayerBar({ entity, name, browse }) {
|
|
|
66
148
|
const seek = useSeekHold(ready ? position : undefined, caps?.seek ? (next) => void handle.command('seek', { position: next }) : undefined);
|
|
67
149
|
const duration = ready ? player?.duration : undefined;
|
|
68
150
|
const timed = seek.shown !== undefined && duration !== undefined && duration > 0;
|
|
151
|
+
// How far along playback is, for the ring round the artwork.
|
|
152
|
+
const progress = timed && duration !== undefined
|
|
153
|
+
? Math.min(1, Math.max(0, (seek.shown ?? 0) / duration))
|
|
154
|
+
: undefined;
|
|
69
155
|
const ref = typeof entity === 'string' ? entity : undefined;
|
|
70
156
|
const browser = browse === false
|
|
71
157
|
? undefined
|
|
72
158
|
: (browse ?? (ref && caps?.browse ? _jsx(MediaBrowser, { entity: ref, layout: "auto" }) : undefined));
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
159
|
+
// What the overlays hold, when the library and the queue open on their own (`overlays`).
|
|
160
|
+
const libraryBody = browse === false
|
|
161
|
+
? undefined
|
|
162
|
+
: (browse ?? (ref && caps?.browse ? _jsx(MediaBrowser, { entity: ref, layout: "list" }) : undefined));
|
|
163
|
+
const queueBody = ref && caps?.queue === true ? _jsx(MediaQueue, { entity: ref }) : undefined;
|
|
164
|
+
const volumeSlider = (_jsxs(Flex, { as: motion.div, align: "center", gap: 2, grow: 1, minWidth: 0, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.15 }, "data-part": "volume", children: [_jsx(PlainButton, { as: motion.button, initial: { scale: 0, opacity: 0 }, animate: { scale: 1, opacity: 1 }, transition: VOLUME_SPRING, "aria-label": muted ? 'Unmute' : 'Mute', "aria-pressed": muted, title: muted ? 'Unmute' : 'Mute', onClick: volume.toggleMute, center: true, color: muted ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: muted ? 'lu:volume-x' : 'lu:volume-2', size: 16 }) }), _jsx(Flex, { as: motion.div, grow: 1, minWidth: 0, initial: { scaleX: 0.4, opacity: 0 }, animate: { scaleX: 1, opacity: 1 }, transition: { ...VOLUME_SPRING, delay: 0.04 }, css: { transformOrigin: 'left center' }, children: _jsx(ValueBar, { label: "Volume level", value: volume.shown, min: 0, max: 100, keyStep: 5, height: 10, onDrag: volume.drag, onCommit: volume.commit }) }), _jsxs(Typography, { as: motion.span, variant: "secondary", color: "textMuted", minWidth: 32, align: "right", css: { flex: 'none' }, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2, delay: 0.1 }, children: [volume.shown, "%"] })] }));
|
|
165
|
+
const trackInfo = (_jsx(_Fragment, { children: _jsxs(Flex, { "data-part": "track", direction: "column", grow: 1, minWidth: 0, ...(ready ? {} : { color: 'textMuted' }), children: [_jsxs(Flex, { align: "center", gap: 1.5, minWidth: 0, children: [_jsx(Typography, { as: "span", variant: "label", noWrap: true, textOverflow: "ellipsis", children: ready
|
|
166
|
+
? (player?.media?.title ?? 'Nothing playing')
|
|
167
|
+
: statusLabels[status] }), ready && caps?.shuffle ? (_jsx(PlainButton, { "aria-label": "Shuffle", "aria-pressed": player?.shuffle === true, title: "Shuffle", onClick: () => void handle.command('setShuffle', { shuffle: player?.shuffle !== true }), center: true, color: player?.shuffle === true ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:shuffle", size: 14 }) })) : null] }), ready && player?.media?.artist ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", children: player.media.artist })) : null] }) }));
|
|
168
|
+
const bar = (open, openExpanded, openQueue) => (_jsxs(HoldCard, { open: open, status: status, rows: rows, volumeOpen: volumeOpen && ready, children: [_jsx(Ring, { onOpen: open, fraction: progress, children: artwork }), trackInfo, timed ? (_jsxs(Typography, { as: "span", "data-part": "time", variant: "secondary", color: "textMuted", noWrap: true, css: { flex: 'none', fontVariantNumeric: 'tabular-nums' }, children: [formatDuration(seek.shown ?? 0), " / ", formatDuration(duration ?? 0)] })) : null, volumeOpen && ready ? volumeSlider : null, _jsxs(Flex, { "data-part": "tools", align: "center", gap: 3, css: { flex: 'none' }, children: [_jsx(IconButton, { icon: volumeOpen ? 'lu:x' : muted ? 'lu:volume-x' : 'lu:volume-2', label: volumeOpen ? 'Close volume' : 'Volume', glyph: MEDIA_GLYPH, disabled: !ready || (caps?.volume === false && caps.mute === false), onClick: () => setVolumeOpen((current) => !current) }), (overlays
|
|
169
|
+
? openExpanded !== undefined
|
|
170
|
+
: open && (onOpen ? browse !== false : browser !== undefined)) ? (_jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: MEDIA_GLYPH, onClick: () => (openExpanded ?? open)?.() })) : null, openQueue ? (_jsx(IconButton, { icon: "lu:list-music", label: "Queue", glyph: MEDIA_GLYPH, onClick: openQueue })) : null] }), _jsxs(Flex, { "data-part": "transport", align: "center", gap: 3, css: { flex: 'none' }, children: [_jsx(IconButton, { icon: "lu:skip-back", label: "Previous", glyph: MEDIA_GLYPH, disabled: !ready || caps?.previous === false, feedback: feedbackFor('previous'), onClick: () => press('previous', () => handle.command('previous')) }), _jsx(IconButton, { icon: playing ? 'lu:pause' : 'lu:play', label: playing ? 'Pause' : 'Play', glyph: MEDIA_GLYPH, disabled: !ready, primary: true, onClick: () => void handle.command('togglePlay') }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: MEDIA_GLYPH, disabled: !ready || caps?.next === false, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) })] }), timed ? (_jsx(SeekLine, { seek: seek, duration: duration ?? 0, seekable: caps?.seek === true })) : null] }));
|
|
76
171
|
// Holding the card, the artwork and the browse button open the same drawer: the player laid out
|
|
77
172
|
// like the vertical one, with the library below it.
|
|
78
|
-
|
|
173
|
+
if (!ready) {
|
|
174
|
+
return bar();
|
|
175
|
+
}
|
|
176
|
+
// The library and the queue are overlays of their own; the card is the player.
|
|
177
|
+
if (overlays) {
|
|
178
|
+
const title = name ?? player?.name ?? fallbackName(entity);
|
|
179
|
+
return (_jsx(DrawerTrigger, { icon: "lu:library", label: "Library", kind: title, body: libraryBody ?? null, children: (_open, _expanded, openLibrary) => (_jsx(DrawerTrigger, { icon: "lu:list-music", label: "Queue", kind: title, body: queueBody ?? null, children: (_openToo, _expandedToo, openQueue) => bar(onOpen, libraryBody ? openLibrary : undefined, queueBody ? openQueue : undefined) })) }));
|
|
180
|
+
}
|
|
181
|
+
// Held, pressed or browsed, the player goes where the dashboard says instead of opening a drawer.
|
|
182
|
+
if (onOpen) {
|
|
183
|
+
return bar(onOpen, onOpen);
|
|
184
|
+
}
|
|
185
|
+
if (!drawer) {
|
|
186
|
+
return bar();
|
|
187
|
+
}
|
|
188
|
+
return (_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) }));
|
|
79
189
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/** The spot being sought to while a drag is in progress, and for a moment after it ends: the
|
|
2
2
|
* player takes a beat to report the new position, and without this the bar would snap back to
|
|
3
|
-
* the old one first. `shown` is what to draw; `preview` follows a drag; `commit` ends it
|
|
3
|
+
* the old one first. `shown` is what to draw; `preview` follows a drag; `commit` ends it and seeks;
|
|
4
|
+
* `cancel` ends it without seeking (a cancelled or lost touch). A drag that is neither committed nor
|
|
5
|
+
* cancelled expires on its own, so the position can never stay stuck. */
|
|
4
6
|
export declare function useSeekHold(position: number | undefined, onSeek: ((position: number) => void) | undefined): {
|
|
5
7
|
shown: number | undefined;
|
|
6
8
|
preview: (next: number) => void;
|
|
7
9
|
commit: (next: number) => void;
|
|
10
|
+
cancel: () => void;
|
|
8
11
|
};
|
|
9
12
|
export type SeekHold = ReturnType<typeof useSeekHold>;
|
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
/** How long a drag may go without a word before it is given up on: a touch whose end never arrived
|
|
3
|
+
* (cancelled by the browser, the finger lifted off the screen) must not leave the position frozen. */
|
|
4
|
+
const IDLE_MS = 8000;
|
|
2
5
|
/** The spot being sought to while a drag is in progress, and for a moment after it ends: the
|
|
3
6
|
* player takes a beat to report the new position, and without this the bar would snap back to
|
|
4
|
-
* the old one first. `shown` is what to draw; `preview` follows a drag; `commit` ends it
|
|
7
|
+
* the old one first. `shown` is what to draw; `preview` follows a drag; `commit` ends it and seeks;
|
|
8
|
+
* `cancel` ends it without seeking (a cancelled or lost touch). A drag that is neither committed nor
|
|
9
|
+
* cancelled expires on its own, so the position can never stay stuck. */
|
|
5
10
|
export function useSeekHold(position, onSeek) {
|
|
6
11
|
const [drag, setDrag] = useState(null);
|
|
7
12
|
const release = useRef(undefined);
|
|
8
|
-
|
|
13
|
+
const idle = useRef(undefined);
|
|
14
|
+
useEffect(() => () => {
|
|
15
|
+
clearTimeout(release.current);
|
|
16
|
+
clearTimeout(idle.current);
|
|
17
|
+
}, []);
|
|
9
18
|
return {
|
|
10
19
|
shown: drag ?? position,
|
|
11
|
-
preview: (next) =>
|
|
20
|
+
preview: (next) => {
|
|
21
|
+
setDrag(next);
|
|
22
|
+
clearTimeout(idle.current);
|
|
23
|
+
idle.current = setTimeout(() => setDrag(null), IDLE_MS);
|
|
24
|
+
},
|
|
12
25
|
commit: (next) => {
|
|
13
26
|
setDrag(next);
|
|
27
|
+
clearTimeout(idle.current);
|
|
14
28
|
clearTimeout(release.current);
|
|
15
29
|
release.current = setTimeout(() => setDrag(null), 1500);
|
|
16
30
|
onSeek?.(next);
|
|
17
31
|
},
|
|
32
|
+
cancel: () => {
|
|
33
|
+
clearTimeout(idle.current);
|
|
34
|
+
clearTimeout(release.current);
|
|
35
|
+
setDrag(null);
|
|
36
|
+
},
|
|
18
37
|
};
|
|
19
38
|
}
|
|
@@ -10,13 +10,13 @@ import { useThemeToggle } from '../provider.js';
|
|
|
10
10
|
* surrounding `Page`, which pads for it. */
|
|
11
11
|
export function NavDock({ items, base, showThemeToggle }) {
|
|
12
12
|
usePageInset('bottom', 88);
|
|
13
|
-
return (_jsxs(Flex, { as: "nav", "aria-label": "Pages", align: "center", gap: 1.5, background: "rail", radius: "full", p: 1.5, shadow: "
|
|
13
|
+
return (_jsxs(Flex, { as: "nav", "aria-label": "Pages", align: "center", gap: 1.5, background: "rail", radius: "full", p: 1.5, shadow: "dock", position: "fixed", css: ({ spacing }) => ({
|
|
14
14
|
bottom: spacing(5),
|
|
15
15
|
left: '50%',
|
|
16
16
|
transform: 'translateX(-50%)',
|
|
17
|
-
}), children: [items.map((item) => (_jsx(NavLink, { to: item.to ? `${base}/${item.to}` : base, end: true, "aria-label": item.label, title: item.label, children: ({ isActive }) => (_jsx(Flex, { as: "span", align: "center", justify: "center", width: 44, height: 44, radius: "
|
|
17
|
+
}), children: [items.map((item) => (_jsx(NavLink, { to: item.to ? `${base}/${item.to}` : base, end: true, "aria-label": item.label, title: item.label, children: ({ isActive }) => (_jsx(Flex, { as: "span", align: "center", justify: "center", width: 44, height: 44, radius: "full", cursor: "pointer", color: isActive ? 'accentText' : 'line', ...(isActive ? { background: 'accent' } : {}), children: _jsx(Icon, { name: item.icon, size: 22 }) })) }, item.to))), showThemeToggle ? _jsx(ThemeToggleButton, {}) : null] }));
|
|
18
18
|
}
|
|
19
19
|
function ThemeToggleButton() {
|
|
20
20
|
const { resolved, toggle } = useThemeToggle();
|
|
21
|
-
return (_jsx(Flex, { as: "button", type: "button", onClick: toggle, "aria-label": `Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`, title: `Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`, align: "center", justify: "center", width: 44, height: 44, radius: "
|
|
21
|
+
return (_jsx(Flex, { as: "button", type: "button", onClick: toggle, "aria-label": `Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`, title: `Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`, align: "center", justify: "center", width: 44, height: 44, radius: "full", cursor: "pointer", color: "line", css: { background: 'none' }, children: _jsx(Icon, { name: resolved === 'dark' ? 'lu:moon' : 'lu:sun', size: 22 }) }));
|
|
22
22
|
}
|
|
@@ -42,6 +42,18 @@ export function SeekLine({ seek, duration, seekable, }) {
|
|
|
42
42
|
seek.commit(positionAt(event));
|
|
43
43
|
}
|
|
44
44
|
},
|
|
45
|
+
// A touch the browser took (a gesture, a scroll) or lost never ends with a release: give
|
|
46
|
+
// the position back to the player instead of leaving it where the finger was.
|
|
47
|
+
onPointerCancel: () => {
|
|
48
|
+
dragging.current = false;
|
|
49
|
+
seek.cancel();
|
|
50
|
+
},
|
|
51
|
+
onLostPointerCapture: () => {
|
|
52
|
+
if (dragging.current) {
|
|
53
|
+
dragging.current = false;
|
|
54
|
+
seek.cancel();
|
|
55
|
+
}
|
|
56
|
+
},
|
|
45
57
|
}
|
|
46
58
|
: { role: 'progressbar' }), "aria-label": "Position", "aria-valuemin": 0, "aria-valuemax": Math.round(duration), "aria-valuenow": Math.round(shown), css: ({ palette }) => ({
|
|
47
59
|
left: 0,
|
|
@@ -58,6 +58,18 @@ export interface SystemStatusProps {
|
|
|
58
58
|
* something connects, red when something is down; click for per-system detail. `TopBar` renders it
|
|
59
59
|
* from live status, so it only needs the props above for demos. */
|
|
60
60
|
export declare function SystemStatus({ link: linkOverride, statuses: statusesOverride, defaultOpen, }: SystemStatusProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
61
|
+
export interface WeatherChipProps {
|
|
62
|
+
/** A weather entity, like `ha:weather.forecast_home`, or a plain temperature sensor. */
|
|
63
|
+
entity: EntityRef;
|
|
64
|
+
/** Tapping a weather entity's pill opens its forecast in a drawer. `false` builds none and the pill is only what it shows, which is lighter on a small display. Default `true`. */
|
|
65
|
+
drawer?: boolean;
|
|
66
|
+
}
|
|
67
|
+
/** Current weather, as a pill: an icon for the sky and a rounded whole-degree reading. A `weather`
|
|
68
|
+
* entity gives the real condition, today's high and low once its forecast has loaded, and opens the
|
|
69
|
+
* forecast when tapped (if its source has one); a plain `sensor.*` (an outdoor temperature) gets a
|
|
70
|
+
* fixed sun and no forecast, since a sensor says nothing about the sky. The top bar's weather pill,
|
|
71
|
+
* for a dashboard that builds its own header instead of using `TopBar`. */
|
|
72
|
+
export declare function WeatherChip({ entity, drawer }: WeatherChipProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
61
73
|
export interface PresenceStackProps {
|
|
62
74
|
/** People, as refs like `ha:person.dan`: one avatar each (their picture, or a colored initial). Someone who is away is dimmed and grayscale. */
|
|
63
75
|
entities: EntityRef[];
|
|
@@ -66,3 +78,12 @@ export interface PresenceStackProps {
|
|
|
66
78
|
}
|
|
67
79
|
/** Overlapping avatar circles for a handful of `person.*` entities. */
|
|
68
80
|
export declare function PresenceStack({ entities, colors }: PresenceStackProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
81
|
+
/** A short date pill (`Tue · Oct 6`), the top bar's, for a dashboard that builds its own header. */
|
|
82
|
+
export declare function DateChip(): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
83
|
+
export interface ClockProps {
|
|
84
|
+
/** `'auto'` follows the browser's locale (browsers don't read the OS 12/24h setting, so an `en-US` browser shows AM/PM); `'12h'`/`'24h'` force it. Default `'auto'`. */
|
|
85
|
+
format?: 'auto' | '12h' | '24h';
|
|
86
|
+
}
|
|
87
|
+
/** The top bar's clock: the time in the large clock type, with AM/PM beside it in a 12h format. For
|
|
88
|
+
* a dashboard that builds its own header instead of using `TopBar`. */
|
|
89
|
+
export declare function Clock({ format }: ClockProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
package/dist/entities/top-bar.js
CHANGED
|
@@ -138,10 +138,12 @@ function StatusChip({ children }) {
|
|
|
138
138
|
return (_jsx(Flex, { align: "center", gap: 2, background: "surface", border: true, radius: "full", px: 3.5, py: 2.5, children: children }));
|
|
139
139
|
}
|
|
140
140
|
/** Current weather, as a pill: an icon for the sky and a rounded whole-degree reading. A `weather`
|
|
141
|
-
* entity gives the real condition
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
* entity gives the real condition, today's high and low once its forecast has loaded, and opens the
|
|
142
|
+
* forecast when tapped (if its source has one); a plain `sensor.*` (an outdoor temperature) gets a
|
|
143
|
+
* fixed sun and no forecast, since a sensor says nothing about the sky. The top bar's weather pill,
|
|
144
|
+
* for a dashboard that builds its own header instead of using `TopBar`. */
|
|
145
|
+
export function WeatherChip({ entity, drawer = true }) {
|
|
146
|
+
return useEntity(entity)?.kind === 'weather' ? (_jsx(WeatherPill, { entity: entity, drawer: drawer })) : (_jsx(SensorWeather, { entity: entity }));
|
|
145
147
|
}
|
|
146
148
|
/** Text boxes cut to the height of the digits, so the reading and the range, in different sizes, are
|
|
147
149
|
* both centred on their digits and not on the font's line box (which sits a little low or high).
|
|
@@ -149,7 +151,7 @@ function WeatherChip({ entity }) {
|
|
|
149
151
|
/** The height of one line of the 12px label text. */
|
|
150
152
|
const LABEL_LINE = 15;
|
|
151
153
|
const TRIM_TO_DIGITS = { textBox: 'trim-both cap alphabetic' };
|
|
152
|
-
function WeatherPill({ entity }) {
|
|
154
|
+
function WeatherPill({ entity, drawer }) {
|
|
153
155
|
const handle = useEntityHandle('weather', entity);
|
|
154
156
|
const { status } = handle;
|
|
155
157
|
const weather = handle.entity;
|
|
@@ -166,7 +168,7 @@ function WeatherPill({ entity }) {
|
|
|
166
168
|
const chip = (_jsxs(StatusChip, { children: [_jsx(Flex, { as: "span", css: { minHeight: LABEL_LINE }, children: _jsx(WeatherIcon, { condition: condition, size: 14 }) }), _jsx(Typography, { as: "span", variant: "label", css: TRIM_TO_DIGITS, children: ready
|
|
167
169
|
? `${Math.round(weather.temperature ?? 0)}°`
|
|
168
170
|
: statusLabels[status] }), ready && range ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", css: TRIM_TO_DIGITS, children: range })) : null] }));
|
|
169
|
-
if (!forecastable) {
|
|
171
|
+
if (!forecastable || !drawer) {
|
|
170
172
|
return chip;
|
|
171
173
|
}
|
|
172
174
|
return (_jsx(DrawerTrigger, { icon: look.icon, label: weather?.name ?? 'Weather', kind: "Weather", body: _jsx(WeatherForecast, { entity: entity }), children: (open) => (_jsx(PlainButton, { "aria-label": `${weather?.name ?? 'Weather'}: forecast`, onClick: open, children: chip })) }));
|
|
@@ -244,13 +246,16 @@ function useNow(intervalMs) {
|
|
|
244
246
|
}, [intervalMs]);
|
|
245
247
|
return now;
|
|
246
248
|
}
|
|
247
|
-
|
|
249
|
+
/** A short date pill (`Tue · Oct 6`), the top bar's, for a dashboard that builds its own header. */
|
|
250
|
+
export function DateChip() {
|
|
248
251
|
const now = useNow(60_000);
|
|
249
252
|
const weekday = now.toLocaleDateString(undefined, { weekday: 'short' });
|
|
250
253
|
const day = now.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
251
254
|
return (_jsx(StatusChip, { children: _jsxs(Typography, { as: "span", variant: "label", children: [weekday, " \u00B7 ", day] }) }));
|
|
252
255
|
}
|
|
253
|
-
|
|
256
|
+
/** The top bar's clock: the time in the large clock type, with AM/PM beside it in a 12h format. For
|
|
257
|
+
* a dashboard that builds its own header instead of using `TopBar`. */
|
|
258
|
+
export function Clock({ format = 'auto' }) {
|
|
254
259
|
const now = useNow(30_000);
|
|
255
260
|
const { time, dayPeriod } = formatClock(now, format);
|
|
256
261
|
return (_jsxs(Flex, { align: "flex-start", gap: 1, children: [_jsx(Typography, { as: "span", variant: "clock", children: time }), dayPeriod ? (_jsx(Typography, { as: "span", variant: "label", css: { textTransform: 'uppercase' }, children: dayPeriod })) : null] }));
|