@hashsome/ui 0.5.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 +78 -61
- package/dist/entities/media-browser.d.ts +3 -1
- package/dist/entities/media-browser.js +41 -15
- package/dist/entities/media-player-bar.js +1 -1
- 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/now-playing.d.ts +3 -1
- package/dist/entities/now-playing.js +8 -3
- package/dist/entities/top-bar.js +6 -2
- package/dist/gallery/gallery.js +3 -1
- package/dist/gallery/props-data.js +102 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -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/drawer-controls.js +18 -6
- package/dist/layout/grid-overlay.d.ts +6 -0
- package/dist/layout/grid-overlay.js +143 -0
- 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 +10 -2
- package/dist/layout/top-row.d.ts +9 -0
- package/dist/layout/top-row.js +10 -0
- package/dist/provider.d.ts +7 -3
- package/dist/provider.js +39 -21
- package/dist/theme/grid.d.ts +30 -0
- package/dist/theme/grid.js +38 -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,67 +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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|
|
96
114
|
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
onPointerCancel: () => {
|
|
101
|
-
drag.current = null;
|
|
102
|
-
seek.cancel();
|
|
103
|
-
},
|
|
104
|
-
onLostPointerCapture: () => {
|
|
105
|
-
if (drag.current) {
|
|
115
|
+
},
|
|
116
|
+
// A touch the browser took or lost never ends with a release: let go of the position.
|
|
117
|
+
onPointerCancel: () => {
|
|
106
118
|
drag.current = null;
|
|
107
119
|
seek.cancel();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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();
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
: {}), children: artworkUrl ? _jsx(Cover, { src: artworkUrl }) : _jsx(Icon, { name: "lu:music", size: 40 }) })] }) }));
|
|
126
143
|
}
|
|
@@ -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. */
|
|
@@ -119,7 +119,7 @@ const stacked = ({ radius, spacing, density, }) => ({
|
|
|
119
119
|
* line along the top that fills while the press is timed, like a tile's. */
|
|
120
120
|
function HoldCard({ open, status, rows, volumeOpen, children, }) {
|
|
121
121
|
const hold = useHold(() => open?.(), open !== undefined);
|
|
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) => ({
|
|
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-grid-card": true, "data-status": status, "data-rows": rows === 2 ? 2 : undefined, "data-volume-open": volumeOpen, css: (theme) => ({
|
|
123
123
|
minHeight: theme.density.tileHeight,
|
|
124
124
|
// In one row the volume slider takes the track's place; in two it has its own row.
|
|
125
125
|
'&[data-volume-open="true"] > [data-part="track"], &[data-volume-open="true"] > [data-part="time"]': { display: 'none' },
|
|
@@ -9,10 +9,13 @@ export interface MediaPlayerColumnProps {
|
|
|
9
9
|
name?: string;
|
|
10
10
|
/** Content for the media browser shown with the player in the drawer. 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 drawer. */
|
|
11
11
|
browse?: ReactNode | false;
|
|
12
|
+
/** The library and the queue each open as a full-size overlay of their own, from a library button and a queue button beside the transport, instead of one half-size drawer with the whole player in it: for a small display, where the drawer's half state is no use. 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. Shuffle moves up beside the title. Default `false`. */
|
|
13
|
+
overlays?: boolean;
|
|
12
14
|
}
|
|
13
15
|
/** The player as an upright card for a narrow column beside a dashboard (a quarter to a third of
|
|
14
16
|
* the width): the artwork in a ring that shows (and seeks) the position, title, transport and a
|
|
15
17
|
* volume bar that is always visible. It is only as tall as it needs to be. Holding the card or
|
|
16
18
|
* pressing the artwork opens the drawer with the player and its library; the browse button opens
|
|
17
|
-
* that drawer expanded.
|
|
18
|
-
|
|
19
|
+
* that drawer expanded. With `overlays` there is no drawer: the library and the queue open full
|
|
20
|
+
* size, each on its own. */
|
|
21
|
+
export declare function MediaPlayerColumn({ entity, name, browse, overlays, }: MediaPlayerColumnProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -14,8 +14,9 @@ import { NowPlaying } from './now-playing.js';
|
|
|
14
14
|
* the width): the artwork in a ring that shows (and seeks) the position, title, transport and a
|
|
15
15
|
* volume bar that is always visible. It is only as tall as it needs to be. Holding the card or
|
|
16
16
|
* pressing the artwork opens the drawer with the player and its library; the browse button opens
|
|
17
|
-
* that drawer expanded.
|
|
18
|
-
|
|
17
|
+
* that drawer expanded. With `overlays` there is no drawer: the library and the queue open full
|
|
18
|
+
* size, each on its own. */
|
|
19
|
+
export function MediaPlayerColumn({ entity, name, browse, overlays = false, }) {
|
|
19
20
|
const handle = useEntityHandle('mediaPlayer', entity);
|
|
20
21
|
const player = handle.entity;
|
|
21
22
|
const ref = typeof entity === 'string' ? entity : undefined;
|
|
@@ -24,12 +25,39 @@ export function MediaPlayerColumn({ entity, name, browse }) {
|
|
|
24
25
|
: (browse ??
|
|
25
26
|
(ref && player?.capabilities.browse ? (_jsx(MediaBrowser, { entity: ref, layout: "auto" })) : undefined));
|
|
26
27
|
const fallback = fallbackName(entity);
|
|
28
|
+
if (overlays) {
|
|
29
|
+
// What the overlays hold: the library as a list, the queue; each only when the player has one.
|
|
30
|
+
const title = name ?? player?.name ?? fallback;
|
|
31
|
+
const libraryBody = browse === false
|
|
32
|
+
? undefined
|
|
33
|
+
: (browse ??
|
|
34
|
+
(ref && player?.capabilities.browse ? (_jsx(MediaBrowser, { entity: ref, layout: "list" })) : undefined));
|
|
35
|
+
const queueBody = ref && player?.capabilities.queue === true ? _jsx(MediaQueue, { entity: ref }) : undefined;
|
|
36
|
+
return handle.status === 'ready' ? (_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) => (_jsx(Card, { children: _jsx(NowPlaying, { handle: handle, fallback: fallback, shuffle: "title", ...(name !== undefined ? { name } : {}), ...(libraryBody
|
|
37
|
+
? {
|
|
38
|
+
leading: (_jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: 18, onClick: openLibrary })),
|
|
39
|
+
}
|
|
40
|
+
: {}), ...(queueBody
|
|
41
|
+
? {
|
|
42
|
+
extra: (_jsx(IconButton, { icon: "lu:list-music", label: "Queue", glyph: 18, onClick: openQueue })),
|
|
43
|
+
}
|
|
44
|
+
: {}) }) })) })) })) : (_jsx(Card, { children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}) }) }));
|
|
45
|
+
}
|
|
27
46
|
return browser !== undefined && handle.status === 'ready' ? (_jsx(DrawerTrigger, { icon: "lu:music", label: name ?? player?.name ?? fallback, kind: "Media", body: _jsx(MediaPlayerFull, { entity: entity, browser: browser, ...(typeof entity === 'string' ? { queue: _jsx(MediaQueue, { entity: entity }) } : {}), ...(name !== undefined ? { name } : {}) }), children: (open, openExpanded) => (_jsx(Card, { open: open, children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}), onOpenArtwork: open, extra: _jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: 18, onClick: openExpanded }) }) })) })) : (_jsx(Card, { children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}) }) }));
|
|
28
47
|
}
|
|
29
48
|
/** The card itself; with `open`, holding it (anywhere but on a control) calls it. */
|
|
30
49
|
function Card({ open, children }) {
|
|
31
50
|
const hold = useHold(() => open?.(), open !== undefined);
|
|
32
|
-
return (_jsxs(Flex, { direction: "column", background: "surface", radius: "card", p: 4, width: "100%",
|
|
33
|
-
// Only as tall as it needs, even in a parent that stretches its children
|
|
34
|
-
|
|
51
|
+
return (_jsxs(Flex, { "data-grid-card": true, direction: "column", background: "surface", radius: "card", p: 4, width: "100%",
|
|
52
|
+
// Only as tall as it needs, even in a parent that stretches its children, and no taller than
|
|
53
|
+
// the parent: the artwork gives way.
|
|
54
|
+
css: {
|
|
55
|
+
alignSelf: 'flex-start',
|
|
56
|
+
position: 'relative',
|
|
57
|
+
overflow: 'hidden',
|
|
58
|
+
maxHeight: '100%',
|
|
59
|
+
minHeight: 0,
|
|
60
|
+
// Stretched beside other cards, the artwork takes the room and the controls stay at the bottom.
|
|
61
|
+
'& > [data-status]': { flex: '1 1 auto' },
|
|
62
|
+
}, ...hold.handlers, children: [children, hold.holding ? _jsx(HoldProgress, {}) : null] }));
|
|
35
63
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource @emotion/react */
|
|
2
2
|
import type { EntityRef } from '@hashsome/core';
|
|
3
|
-
import type
|
|
3
|
+
import { type ReactNode } from 'react';
|
|
4
4
|
import { type EntityHandle } from '../entity-handle.ts';
|
|
5
5
|
export interface MediaPlayerFullProps {
|
|
6
6
|
/** The media player, as a ref like `ma:living_room`. */
|
|
@@ -13,10 +13,20 @@ export interface MediaPlayerFullProps {
|
|
|
13
13
|
name?: string;
|
|
14
14
|
/** Gives the library the whole width, for one laid out to use it (the theater layout). Defaults to whether the drawer is expanded. */
|
|
15
15
|
wide?: boolean;
|
|
16
|
+
/** In the narrow layout, where the queue has no room beside the player, a Library and a Queue tab above the list switch between them (shown when there is a `browser`, a `queue` and a player with a queue). `false` leaves the library alone, as before. In the wide layout the queue is beside the player and there are no tabs. Default `true`. */
|
|
17
|
+
tabs?: boolean;
|
|
18
|
+
/** The tab that is open first, when the tabs are on. Default `'library'`. */
|
|
19
|
+
defaultTab?: ListTab;
|
|
20
|
+
/** The open tab, to keep it yourself (to remember it between visits, or open the queue from elsewhere); the tabs ask for a change through `onTabChange`. Without it, the component keeps it, until it is gone. */
|
|
21
|
+
tab?: ListTab;
|
|
22
|
+
/** Called with the tab someone chose. */
|
|
23
|
+
onTabChange?: (tab: ListTab) => void;
|
|
16
24
|
}
|
|
25
|
+
/** What the tabs below the player choose between. */
|
|
26
|
+
export type ListTab = 'library' | 'queue';
|
|
17
27
|
/** The big player: the player centered at the top, and the library (or whatever `browser` is) below
|
|
18
28
|
* it. It is what the media cards' drawers show, at every width, and it is the widget a dashboard puts
|
|
19
29
|
* in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
|
|
20
30
|
* heading, other cards beside it). It fills the height it is given, so the page that holds it
|
|
21
31
|
* decides how tall that is. */
|
|
22
|
-
export declare function MediaPlayerFull({ entity, browser, queue, wide, name }: MediaPlayerFullProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
32
|
+
export declare function MediaPlayerFull({ entity, browser, queue, wide, name, tabs, defaultTab, tab, onTabChange, }: MediaPlayerFullProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -1,19 +1,61 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
|
|
2
|
-
import { Box, Flex } from 'e-prim';
|
|
2
|
+
import { Box, Flex, Typography } from 'e-prim';
|
|
3
|
+
import { motion } from 'motion/react';
|
|
4
|
+
import { useId, useState } from 'react';
|
|
3
5
|
import { fallbackName } from '../entity-handle.js';
|
|
4
6
|
import { useEntityHandle } from '../hooks.js';
|
|
5
7
|
import { useDetail } from '../layout/detail-provider.js';
|
|
8
|
+
import { Icon } from '../icon.js';
|
|
6
9
|
import { NowPlaying } from './now-playing.js';
|
|
7
10
|
/** The artwork ring's diameter in a full-size player, against the usual 168px. */
|
|
8
11
|
const FULL_SIZE = 260;
|
|
12
|
+
const TAB_HEIGHT = 40;
|
|
13
|
+
/** Library and Queue, as text with an underline (not the pills the library's own categories are, so the
|
|
14
|
+
* two rows read as different things). */
|
|
15
|
+
function ListTabs({ value, onChange }) {
|
|
16
|
+
// One line that slides between the tabs; its own id, so two players on a page do not share it.
|
|
17
|
+
const line = useId();
|
|
18
|
+
const tabs = [
|
|
19
|
+
{ id: 'library', label: 'Library', icon: 'lu:library' },
|
|
20
|
+
{ id: 'queue', label: 'Queue', icon: 'lu:list-music' },
|
|
21
|
+
];
|
|
22
|
+
return (_jsx(Flex, { role: "tablist", "aria-label": "Library or queue", gap: 5, css: ({ palette }) => ({ flexShrink: 0, borderBottom: `1px solid ${palette.border}` }), children: tabs.map((tab) => {
|
|
23
|
+
const selected = tab.id === value;
|
|
24
|
+
return (_jsxs(Flex, { as: "button", type: "button", role: "tab", "aria-selected": selected, onClick: () => onChange(tab.id), align: "center", justify: "center", gap: 2, height: TAB_HEIGHT, cursor: "pointer", color: selected ? 'text' : 'textMuted', css: {
|
|
25
|
+
flex: 1,
|
|
26
|
+
background: 'none',
|
|
27
|
+
border: 0,
|
|
28
|
+
padding: 0,
|
|
29
|
+
position: 'relative',
|
|
30
|
+
marginBottom: -1,
|
|
31
|
+
}, children: [selected ? (_jsx(Box, { as: motion.span, layoutId: line, position: "absolute", background: "accent", transition: { duration: 0.2, ease: 'easeOut' }, css: { left: 0, right: 0, bottom: 0, height: 2 } })) : null, _jsx(Icon, { name: tab.icon, size: 16 }), _jsx(Typography, { as: "span", variant: selected ? 'bodyStrong' : 'body', children: tab.label })] }, tab.id));
|
|
32
|
+
}) }));
|
|
33
|
+
}
|
|
9
34
|
/** The big player: the player centered at the top, and the library (or whatever `browser` is) below
|
|
10
35
|
* it. It is what the media cards' drawers show, at every width, and it is the widget a dashboard puts
|
|
11
36
|
* in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
|
|
12
37
|
* heading, other cards beside it). It fills the height it is given, so the page that holds it
|
|
13
38
|
* decides how tall that is. */
|
|
14
|
-
export function MediaPlayerFull({ entity, browser, queue, wide, name }) {
|
|
39
|
+
export function MediaPlayerFull({ entity, browser, queue, wide, name, tabs = true, defaultTab = 'library', tab, onTabChange, }) {
|
|
15
40
|
const handle = useEntityHandle('mediaPlayer', entity);
|
|
16
41
|
const { detail } = useDetail();
|
|
17
42
|
const full = wide ?? detail?.expanded === true;
|
|
18
|
-
|
|
43
|
+
// Narrow, the queue and the library take turns in the one space below the player.
|
|
44
|
+
const [kept, setKept] = useState(defaultTab);
|
|
45
|
+
const hasQueue = queue !== undefined && handle.entity?.capabilities.queue === true;
|
|
46
|
+
const lists = [
|
|
47
|
+
...(browser !== undefined ? ['library'] : []),
|
|
48
|
+
...(!full && tabs && hasQueue ? ['queue'] : []),
|
|
49
|
+
];
|
|
50
|
+
const wanted = tab ?? kept;
|
|
51
|
+
const open = lists.includes(wanted) ? wanted : lists[0];
|
|
52
|
+
const choose = (next) => {
|
|
53
|
+
setKept(next);
|
|
54
|
+
onTabChange?.(next);
|
|
55
|
+
};
|
|
56
|
+
return (_jsxs(Flex, { direction: "column", gap: 5, grow: 1, minHeight: 0, children: [_jsxs(Flex, { align: "stretch", justify: "center", gap: 6, ...(full ? { grow: 1 } : {}), css: { flexShrink: 0 }, children: [_jsx(Flex, { align: "center", justify: "center", grow: 1, minWidth: 0, children: _jsx(Box, { width: "100%", maxWidth: full ? 560 : 420, children: _jsx(NowPlaying, { handle: handle, fallback: fallbackName(entity), ...(name !== undefined ? { name } : {}), shuffle: "title", ...(full ? { size: FULL_SIZE } : {}) }) }) }), full && queue !== undefined && handle.entity?.capabilities.queue === true ? (_jsx(Flex, { position: "relative", width: 400, css: ({ palette }) => ({ flex: 'none', borderLeft: `1px solid ${palette.border}` }), children: _jsx(Flex, { direction: "column", position: "absolute", pl: 5, css: { inset: 0 }, children: queue }) })) : null] }), open !== undefined ? (_jsxs(_Fragment, { children: [lists.length > 1 ? (_jsx(ListTabs, { value: open, onChange: choose })) : (_jsx(Box, { height: 1, background: "border", css: { flexShrink: 0 } })), _jsx(Flex
|
|
57
|
+
// Another list fades in where the last one was, so a tab does not cut from one to the other.
|
|
58
|
+
, {
|
|
59
|
+
// Another list fades in where the last one was, so a tab does not cut from one to the other.
|
|
60
|
+
as: motion.div, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.15, ease: 'easeOut' }, direction: "column", width: "100%", minHeight: 0, ...(full ? {} : { maxWidth: 960, mx: 'auto' }), children: open === 'queue' ? queue : browser }, open)] })) : null] }));
|
|
19
61
|
}
|