@hashsome/ui 0.11.0 → 0.12.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/media-browser.js +1 -1
- package/dist/entities/media-pending.d.ts +3 -1
- package/dist/entities/media-pending.js +24 -5
- package/dist/entities/media-player-bar.d.ts +5 -1
- package/dist/entities/media-player-bar.js +6 -3
- package/dist/entities/media-player-column.d.ts +5 -1
- package/dist/entities/media-player-column.js +12 -3
- package/dist/entities/media-player-full.d.ts +7 -1
- package/dist/entities/media-player-full.js +14 -4
- package/dist/entities/media-queue.js +1 -0
- package/dist/entities/media-speakers-pill.d.ts +44 -0
- package/dist/entities/media-speakers-pill.js +73 -0
- package/dist/entities/media-speakers.d.ts +20 -0
- package/dist/entities/media-speakers.js +133 -0
- package/dist/entities/now-playing.d.ts +3 -1
- package/dist/entities/now-playing.js +2 -2
- package/dist/gallery/fixtures.js +50 -0
- package/dist/gallery/gallery.js +6 -1
- package/dist/gallery/props-data.js +65 -0
- package/dist/hooks.d.ts +3 -0
- package/dist/hooks.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +2 -2
|
@@ -134,7 +134,7 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
|
|
|
134
134
|
ariaLabel: 'Back',
|
|
135
135
|
}
|
|
136
136
|
: { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) })) }), actionsHere, searchTrigger] })) : null;
|
|
137
|
-
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, asked: askedOf(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, asked: askedOf(item), queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onNext: () => playNext(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) }))] }));
|
|
137
|
+
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, asked: askedOf(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', overflowX: 'hidden' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseRow, { item: item, asked: askedOf(item), queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onNext: () => playNext(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) }))] }));
|
|
138
138
|
}
|
|
139
139
|
/** What shows where the list will be while it loads: placeholders shaped like what is coming (cards in
|
|
140
140
|
* the theater layout, rows in the list), in the room the real thing takes, so nothing moves. */
|
|
@@ -4,7 +4,9 @@ import type { EntityRef } from '@hashsome/core';
|
|
|
4
4
|
* changes until it does: so the button, the item and the player say it is waiting. */
|
|
5
5
|
export interface MediaPending {
|
|
6
6
|
/** What was asked. A transport command is its own name (`togglePlay`, `next`, `previous`, `setShuffle`);
|
|
7
|
-
* from the library: `playItem` (replaces what is playing, so the player says it is loading it), `queueNext`, `queueAdd
|
|
7
|
+
* from the library: `playItem` (replaces what is playing, so the player says it is loading it), `queueNext`, `queueAdd`;
|
|
8
|
+
* and a change of who plays together starts with `group:` (`group:add:<speaker>`, `group:reset`...), which is done
|
|
9
|
+
* when the player's group changes. */
|
|
8
10
|
kind: string;
|
|
9
11
|
/** For the library kinds: the item it was asked for. */
|
|
10
12
|
item?: string;
|
|
@@ -14,7 +14,17 @@ const changesTrack = (kind) => kind === 'next' || kind === 'previous' || kind ==
|
|
|
14
14
|
/** How long after the player acknowledges a request to wait for it to take effect, when it never
|
|
15
15
|
* visibly does (a skip back on a track already at its start). A skip or a start from the library
|
|
16
16
|
* takes the player several seconds. */
|
|
17
|
-
const settleFor = (kind) => kind === 'playItem'
|
|
17
|
+
const settleFor = (kind) => kind === 'playItem'
|
|
18
|
+
? 15_000
|
|
19
|
+
: kind === 'next'
|
|
20
|
+
? 10_000
|
|
21
|
+
: kind === 'previous'
|
|
22
|
+
? 4_000
|
|
23
|
+
: changesGroup(kind)
|
|
24
|
+
? 8_000
|
|
25
|
+
: 2_000;
|
|
26
|
+
/** Asks that change who plays together are done when the group is another. */
|
|
27
|
+
const changesGroup = (kind) => kind.startsWith('group:');
|
|
18
28
|
/** A position past this many seconds means the track plays, and is not only announced. */
|
|
19
29
|
const STARTED_AFTER_S = 0.05;
|
|
20
30
|
/** At least this long is shown for a request, so a quick answer still shows a turn. */
|
|
@@ -34,7 +44,12 @@ const publish = (ref) => {
|
|
|
34
44
|
/** Shows `entry` as pending for `ref` from now until the player has taken it up, whichever way: until it
|
|
35
45
|
* refuses it, or changes after acknowledging it, or a while after (`useMediaPending` reports the change). */
|
|
36
46
|
export function trackMedia(ref, entry, request) {
|
|
37
|
-
const waiting = {
|
|
47
|
+
const waiting = {
|
|
48
|
+
entry,
|
|
49
|
+
answered: false,
|
|
50
|
+
baseTitle: lastSeen.get(ref)?.title,
|
|
51
|
+
baseGroup: lastSeen.get(ref)?.group,
|
|
52
|
+
};
|
|
38
53
|
const started = Date.now();
|
|
39
54
|
pending.set(ref, [...(pending.get(ref) ?? []), waiting]);
|
|
40
55
|
publish(ref);
|
|
@@ -72,7 +87,9 @@ export function trackMedia(ref, entry, request) {
|
|
|
72
87
|
function observe(ref, seen) {
|
|
73
88
|
lastSeen.set(ref, seen);
|
|
74
89
|
for (const waiting of pending.get(ref) ?? []) {
|
|
75
|
-
const done =
|
|
90
|
+
const done = changesGroup(waiting.entry.kind)
|
|
91
|
+
? seen.group !== waiting.baseGroup
|
|
92
|
+
: !changesTrack(waiting.entry.kind) || (seen.title !== waiting.baseTitle && seen.started);
|
|
76
93
|
if (!done) {
|
|
77
94
|
continue;
|
|
78
95
|
}
|
|
@@ -110,12 +127,14 @@ export function useMediaPending(ref) {
|
|
|
110
127
|
const playback = media?.playback;
|
|
111
128
|
const album = media?.media?.album;
|
|
112
129
|
const shuffle = media?.shuffle;
|
|
130
|
+
// Who plays together, as one text: it changes when someone joins or leaves, whoever leads.
|
|
131
|
+
const group = media?.group ? `${media.group.leader}>${media.group.members.join(',')}` : '';
|
|
113
132
|
useEffect(() => {
|
|
114
133
|
if (ref && present) {
|
|
115
|
-
observe(ref, { title, started });
|
|
134
|
+
observe(ref, { title, started, group });
|
|
116
135
|
}
|
|
117
136
|
// `playback`, `album` and `shuffle` are not read inside: a change to any of them is the reason to look again.
|
|
118
137
|
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
|
119
|
-
}, [ref, present, title, started, playback, album, shuffle]);
|
|
138
|
+
}, [ref, present, title, started, group, playback, album, shuffle]);
|
|
120
139
|
return useSyncExternalStore(subscribe, () => (ref ? (view.get(ref) ?? NONE) : NONE), () => NONE);
|
|
121
140
|
}
|
|
@@ -15,8 +15,12 @@ export interface MediaPlayerBarProps {
|
|
|
15
15
|
onOpen?: () => void;
|
|
16
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
17
|
overlays?: boolean;
|
|
18
|
+
/** Whether the player can add speakers to what it plays, put it back to just itself and join a stream playing elsewhere (`MediaSpeakers`), from a speakers button with `overlays` and a Speakers tab in the drawer. Only for a player that can be grouped. `false` leaves it out. Default `true`. */
|
|
19
|
+
grouping?: boolean;
|
|
20
|
+
/** An allowlist of the speakers that can be added: only those that are in it and that the player can be grouped with are offered. Absent, every speaker it can be grouped with is offered. */
|
|
21
|
+
speakers?: EntityRef[];
|
|
18
22
|
/** `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
23
|
rows?: 1 | 2 | 'auto';
|
|
20
24
|
}
|
|
21
25
|
/** Now playing with previous / play-pause / next and a volume control, for a media player entity. */
|
|
22
|
-
export declare function MediaPlayerBar({ entity, name, browse, drawer, onOpen, overlays, rows, }: MediaPlayerBarProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
26
|
+
export declare function MediaPlayerBar({ entity, name, browse, drawer, onOpen, overlays, grouping, speakers, rows, }: MediaPlayerBarProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -19,6 +19,7 @@ import { MediaBrowser } from './media-browser.js';
|
|
|
19
19
|
import { LoadingLine, useLoadingLabel } from './media-loading-line.js';
|
|
20
20
|
import { MediaPlayerFull } from './media-player-full.js';
|
|
21
21
|
import { MediaQueue } from './media-queue.js';
|
|
22
|
+
import { SpeakersOverlay } from './media-speakers-pill.js';
|
|
22
23
|
import { formatDuration, useMediaPosition, useSteadyPlaying } from './media-progress.js';
|
|
23
24
|
import { useSeekHold } from './media-seek.js';
|
|
24
25
|
import { SeekLine } from './seek-line.js';
|
|
@@ -134,7 +135,7 @@ function HoldCard({ open, status, rows, volumeOpen, children, }) {
|
|
|
134
135
|
return rows === 'auto' ? (_jsx(Flex, { width: "100%", css: { containerType: 'inline-size' }, children: card })) : (card);
|
|
135
136
|
}
|
|
136
137
|
/** Now playing with previous / play-pause / next and a volume control, for a media player entity. */
|
|
137
|
-
export function MediaPlayerBar({ entity, name, browse, drawer = true, onOpen, overlays = false, rows = 'auto', }) {
|
|
138
|
+
export function MediaPlayerBar({ entity, name, browse, drawer = true, onOpen, overlays = false, grouping = true, speakers, rows = 'auto', }) {
|
|
138
139
|
const handle = useEntityHandle('mediaPlayer', entity);
|
|
139
140
|
const player = handle.entity;
|
|
140
141
|
const status = handle.status;
|
|
@@ -165,13 +166,15 @@ export function MediaPlayerBar({ entity, name, browse, drawer = true, onOpen, ov
|
|
|
165
166
|
? undefined
|
|
166
167
|
: (browse ?? (ref && caps?.browse ? _jsx(MediaBrowser, { entity: ref, layout: "list" }) : undefined));
|
|
167
168
|
const queueBody = ref && caps?.queue === true ? _jsx(MediaQueue, { entity: ref }) : undefined;
|
|
169
|
+
// And whether it can be grouped, for the pill that opens who plays together with it.
|
|
170
|
+
const grouped = grouping && ref !== undefined && caps?.group === true;
|
|
168
171
|
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, "%"] })] }));
|
|
169
172
|
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
|
|
170
173
|
? (player?.media?.title ?? 'Nothing playing')
|
|
171
174
|
: statusLabels[status] }), ready && caps?.shuffle ? (_jsx(PlainButton, { "aria-label": "Shuffle", "aria-pressed": player?.shuffle === true, title: "Shuffle", onClick: () => press('setShuffle', () => handle.command('setShuffle', { shuffle: player?.shuffle !== true })), center: true, color: player?.shuffle === true ? 'accent' : 'textMuted', css: { flex: 'none' }, children: feedbackFor('setShuffle') === 'pending' ? (_jsx(Spinner, { size: 14 })) : (_jsx(Icon, { name: "lu:shuffle", size: 14 })) })) : null] }), loading ? (_jsx(LoadingLine, { label: loading, variant: "secondary" })) : ready && player?.media?.artist ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", children: player.media.artist })) : null] }) }));
|
|
172
175
|
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
|
|
173
176
|
? openExpanded !== undefined
|
|
174
|
-
: 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 || !can.previous, 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 || !can.play, primary: true, feedback: feedbackFor('togglePlay'), onClick: () => press('togglePlay', () => handle.command('togglePlay')) }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: MEDIA_GLYPH, disabled: !ready || caps?.next === false || !can.next, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) })] }), timed ? (_jsx(SeekLine, { seek: seek, duration: duration ?? 0, seekable: caps?.seek === true })) : null] }));
|
|
177
|
+
: 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, grouped && ref && ready ? (_jsx(SpeakersOverlay, { player: player, entity: ref, name: name, title: name ?? player?.name ?? fallbackName(entity), allowed: speakers, compact: true })) : 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 || !can.previous, 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 || !can.play, primary: true, feedback: feedbackFor('togglePlay'), onClick: () => press('togglePlay', () => handle.command('togglePlay')) }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: MEDIA_GLYPH, disabled: !ready || caps?.next === false || !can.next, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) })] }), timed ? (_jsx(SeekLine, { seek: seek, duration: duration ?? 0, seekable: caps?.seek === true })) : null] }));
|
|
175
178
|
// Holding the card, the artwork and the browse button open the same drawer: the player laid out
|
|
176
179
|
// like the vertical one, with the library below it.
|
|
177
180
|
if (!ready) {
|
|
@@ -189,5 +192,5 @@ export function MediaPlayerBar({ entity, name, browse, drawer = true, onOpen, ov
|
|
|
189
192
|
if (!drawer) {
|
|
190
193
|
return bar();
|
|
191
194
|
}
|
|
192
|
-
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) }));
|
|
195
|
+
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 }) } : {}), grouping: grouping, speakersView: "inline", ...(speakers ? { speakers } : {}) }), children: (open, openExpanded) => bar(open, openExpanded) }));
|
|
193
196
|
}
|
|
@@ -11,6 +11,10 @@ export interface MediaPlayerColumnProps {
|
|
|
11
11
|
browse?: ReactNode | false;
|
|
12
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
13
|
overlays?: boolean;
|
|
14
|
+
/** Whether the player can add speakers to what it plays, put it back to just itself and join a stream playing elsewhere (`MediaSpeakers`): a Speakers tab in the drawer's player, and with `overlays` a speakers button beside the title that opens it. Only for a player that can be grouped. `false` leaves it out. Default `true`. */
|
|
15
|
+
grouping?: boolean;
|
|
16
|
+
/** An allowlist of the speakers that can be added: only those that are in it and that the player can be grouped with are offered. Absent, every speaker it can be grouped with is offered. */
|
|
17
|
+
speakers?: EntityRef[];
|
|
14
18
|
}
|
|
15
19
|
/** The player as an upright card for a narrow column beside a dashboard (a quarter to a third of
|
|
16
20
|
* the width): the artwork in a ring that shows (and seeks) the position, title, transport and a
|
|
@@ -18,4 +22,4 @@ export interface MediaPlayerColumnProps {
|
|
|
18
22
|
* pressing the artwork opens the drawer with the player and its library; the browse button opens
|
|
19
23
|
* that drawer expanded. With `overlays` there is no drawer: the library and the queue open full
|
|
20
24
|
* size, each on its own. */
|
|
21
|
-
export declare function MediaPlayerColumn({ entity, name, browse, overlays, }: MediaPlayerColumnProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
25
|
+
export declare function MediaPlayerColumn({ entity, name, browse, overlays, grouping, speakers, }: MediaPlayerColumnProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -9,6 +9,7 @@ import { useHold } from '../layout/use-hold.js';
|
|
|
9
9
|
import { MediaBrowser } from './media-browser.js';
|
|
10
10
|
import { MediaPlayerFull } from './media-player-full.js';
|
|
11
11
|
import { MediaQueue } from './media-queue.js';
|
|
12
|
+
import { SpeakersOverlay } from './media-speakers-pill.js';
|
|
12
13
|
import { NowPlaying } from './now-playing.js';
|
|
13
14
|
/** The player as an upright card for a narrow column beside a dashboard (a quarter to a third of
|
|
14
15
|
* the width): the artwork in a ring that shows (and seeks) the position, title, transport and a
|
|
@@ -16,7 +17,7 @@ import { NowPlaying } from './now-playing.js';
|
|
|
16
17
|
* pressing the artwork opens the drawer with the player and its library; the browse button opens
|
|
17
18
|
* that drawer expanded. With `overlays` there is no drawer: the library and the queue open full
|
|
18
19
|
* size, each on its own. */
|
|
19
|
-
export function MediaPlayerColumn({ entity, name, browse, overlays = false, }) {
|
|
20
|
+
export function MediaPlayerColumn({ entity, name, browse, overlays = false, grouping = true, speakers, }) {
|
|
20
21
|
const handle = useEntityHandle('mediaPlayer', entity);
|
|
21
22
|
const player = handle.entity;
|
|
22
23
|
const ref = typeof entity === 'string' ? entity : undefined;
|
|
@@ -25,6 +26,14 @@ export function MediaPlayerColumn({ entity, name, browse, overlays = false, }) {
|
|
|
25
26
|
: (browse ??
|
|
26
27
|
(ref && player?.capabilities.browse ? (_jsx(MediaBrowser, { entity: ref, layout: "auto" })) : undefined));
|
|
27
28
|
const fallback = fallbackName(entity);
|
|
29
|
+
// Whether it can be grouped, for the pill under the track info that opens who plays together with it.
|
|
30
|
+
const grouped = grouping && ref !== undefined && player?.capabilities.group === true;
|
|
31
|
+
// The pill under the track info, which opens the speakers over the player.
|
|
32
|
+
const pill = grouped && ref
|
|
33
|
+
? {
|
|
34
|
+
speakers: (_jsx(SpeakersOverlay, { player: player, entity: ref, name: name, title: name ?? player?.name ?? fallback, allowed: speakers })),
|
|
35
|
+
}
|
|
36
|
+
: {};
|
|
28
37
|
if (overlays) {
|
|
29
38
|
// What the overlays hold: the library as a list, the queue; each only when the player has one.
|
|
30
39
|
const title = name ?? player?.name ?? fallback;
|
|
@@ -41,9 +50,9 @@ export function MediaPlayerColumn({ entity, name, browse, overlays = false, }) {
|
|
|
41
50
|
? {
|
|
42
51
|
extra: (_jsx(IconButton, { icon: "lu:list-music", label: "Queue", glyph: 18, onClick: openQueue })),
|
|
43
52
|
}
|
|
44
|
-
: {}) }) })) })) })) : (_jsx(Card, { children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}) }) }));
|
|
53
|
+
: {}), ...pill }) })) })) })) : (_jsx(Card, { children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}), ...pill }) }));
|
|
45
54
|
}
|
|
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 } : {}) }) }));
|
|
55
|
+
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 }) } : {}), ...(speakers ? { speakers } : {}), grouping: grouping, speakersView: "inline", ...(name !== undefined ? { name } : {}) }), children: (open, openExpanded) => (_jsx(Card, { open: open, children: _jsx(NowPlaying, { handle: handle, fallback: fallback, ...(name !== undefined ? { name } : {}), onOpenArtwork: open, ...pill, 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 } : {}), ...pill }) }));
|
|
47
56
|
}
|
|
48
57
|
/** The card itself; with `open`, holding it (anywhere but on a control) calls it. */
|
|
49
58
|
function Card({ open, children }) {
|
|
@@ -9,6 +9,12 @@ export interface MediaPlayerFullProps {
|
|
|
9
9
|
browser?: ReactNode;
|
|
10
10
|
/** What goes beside the player in a wide space, usually the player's queue: `<MediaQueue entity="ma:living_room" />`. Only used when `wide` and the player has a queue; the narrow layout has no room for it. */
|
|
11
11
|
queue?: ReactNode;
|
|
12
|
+
/** Whether the player can add speakers to what it plays, put it back to just itself and join a stream playing elsewhere (`MediaSpeakers`): a pill under the track info says how its speakers stand and opens them. Only for a player given as a ref that can be grouped. `false` leaves it out. Default `true`. */
|
|
13
|
+
grouping?: boolean;
|
|
14
|
+
/** An allowlist of the speakers that can be added: only those that are in it and that the player can be grouped with are offered. Absent, every speaker it can be grouped with is offered. */
|
|
15
|
+
speakers?: EntityRef[];
|
|
16
|
+
/** How the pill opens the speakers: `overlay` over the player, in the side panel the media overlays use, or `inline`, in the place of the list below the player, for a player that is already in a panel. Default `overlay`. */
|
|
17
|
+
speakersView?: 'overlay' | 'inline';
|
|
12
18
|
/** Calls the player this instead of the name it reports. */
|
|
13
19
|
name?: string;
|
|
14
20
|
/** Gives the library the whole width, for one laid out to use it (the theater layout). Defaults to whether the drawer is expanded. */
|
|
@@ -29,4 +35,4 @@ export type ListTab = 'library' | 'queue';
|
|
|
29
35
|
* in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
|
|
30
36
|
* heading, other cards beside it). It fills the height it is given, so the page that holds it
|
|
31
37
|
* decides how tall that is. */
|
|
32
|
-
export declare function MediaPlayerFull({ entity, browser, queue, wide, name, tabs, defaultTab, tab, onTabChange, }: MediaPlayerFullProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
38
|
+
export declare function MediaPlayerFull({ entity, browser, queue, grouping, speakers, speakersView, wide, name, tabs, defaultTab, tab, onTabChange, }: MediaPlayerFullProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -6,19 +6,22 @@ import { fallbackName } from '../entity-handle.js';
|
|
|
6
6
|
import { useEntityHandle } from '../hooks.js';
|
|
7
7
|
import { useDetail } from '../layout/detail-provider.js';
|
|
8
8
|
import { Icon } from '../icon.js';
|
|
9
|
+
import { MediaSpeakers } from './media-speakers.js';
|
|
10
|
+
import { SpeakersOverlay, SpeakersPill } from './media-speakers-pill.js';
|
|
9
11
|
import { NowPlaying } from './now-playing.js';
|
|
10
12
|
/** The artwork ring's diameter in a full-size player, against the usual 168px. */
|
|
11
13
|
const FULL_SIZE = 260;
|
|
12
14
|
const TAB_HEIGHT = 40;
|
|
13
15
|
/** Library and Queue, as text with an underline (not the pills the library's own categories are, so the
|
|
14
16
|
* two rows read as different things). */
|
|
15
|
-
function ListTabs({ value, onChange }) {
|
|
17
|
+
function ListTabs({ value, lists, onChange, }) {
|
|
16
18
|
// One line that slides between the tabs; its own id, so two players on a page do not share it.
|
|
17
19
|
const line = useId();
|
|
18
|
-
const
|
|
20
|
+
const all = [
|
|
19
21
|
{ id: 'library', label: 'Library', icon: 'lu:library' },
|
|
20
22
|
{ id: 'queue', label: 'Queue', icon: 'lu:list-music' },
|
|
21
23
|
];
|
|
24
|
+
const tabs = all.filter((tab) => lists.includes(tab.id));
|
|
22
25
|
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
26
|
const selected = tab.id === value;
|
|
24
27
|
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: {
|
|
@@ -36,7 +39,7 @@ function ListTabs({ value, onChange }) {
|
|
|
36
39
|
* in a page of its own, around it whatever it likes (a surface that fills the space and scrolls, a
|
|
37
40
|
* heading, other cards beside it). It fills the height it is given, so the page that holds it
|
|
38
41
|
* decides how tall that is. */
|
|
39
|
-
export function MediaPlayerFull({ entity, browser, queue, wide, name, tabs = true, defaultTab = 'library', tab, onTabChange, }) {
|
|
42
|
+
export function MediaPlayerFull({ entity, browser, queue, grouping = true, speakers, speakersView = 'overlay', wide, name, tabs = true, defaultTab = 'library', tab, onTabChange, }) {
|
|
40
43
|
const handle = useEntityHandle('mediaPlayer', entity);
|
|
41
44
|
const { detail } = useDetail();
|
|
42
45
|
const full = wide ?? detail?.expanded === true;
|
|
@@ -47,13 +50,20 @@ export function MediaPlayerFull({ entity, browser, queue, wide, name, tabs = tru
|
|
|
47
50
|
...(browser !== undefined ? ['library'] : []),
|
|
48
51
|
...(!full && tabs && hasQueue ? ['queue'] : []),
|
|
49
52
|
];
|
|
53
|
+
// The speakers, behind a pill under the track info.
|
|
54
|
+
const ref = typeof entity === 'string' ? entity : undefined;
|
|
55
|
+
const player = handle.entity;
|
|
56
|
+
const [speakersOpen, setSpeakersOpen] = useState(false);
|
|
57
|
+
const speakersBody = grouping && ref && player?.capabilities.group === true ? (_jsx(MediaSpeakers, { entity: ref, ...(name !== undefined ? { name } : {}), ...(speakers ? { speakers } : {}) })) : undefined;
|
|
58
|
+
const pill = speakersBody && ref ? (speakersView === 'inline' ? (_jsx(SpeakersPill, { player: player, speakers: speakers, pressed: speakersOpen, onClick: () => setSpeakersOpen((now) => !now) })) : (_jsx(SpeakersOverlay, { player: player, entity: ref, name: name, title: name ?? player?.name ?? fallbackName(entity), allowed: speakers }))) : undefined;
|
|
59
|
+
const showSpeakers = speakersView === 'inline' && speakersOpen && speakersBody !== undefined;
|
|
50
60
|
const wanted = tab ?? kept;
|
|
51
61
|
const open = lists.includes(wanted) ? wanted : lists[0];
|
|
52
62
|
const choose = (next) => {
|
|
53
63
|
setKept(next);
|
|
54
64
|
onTabChange?.(next);
|
|
55
65
|
};
|
|
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
|
|
66
|
+
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", ...(pill ? { speakers: pill } : {}), ...(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] }), showSpeakers ? (_jsxs(_Fragment, { children: [_jsx(Box, { height: 1, background: "border", css: { flexShrink: 0 } }), _jsx(Flex, { direction: "column", width: "100%", minHeight: 0, ...(full ? {} : { maxWidth: 960, mx: 'auto' }), children: speakersBody })] })) : open !== undefined ? (_jsxs(_Fragment, { children: [lists.length > 1 ? (_jsx(ListTabs, { value: open, lists: lists, onChange: choose })) : (_jsx(Box, { height: 1, background: "border", css: { flexShrink: 0 } })), _jsx(Flex
|
|
57
67
|
// Another list fades in where the last one was, so a tab does not cut from one to the other.
|
|
58
68
|
, {
|
|
59
69
|
// Another list fades in where the last one was, so a tab does not cut from one to the other.
|
|
@@ -99,6 +99,7 @@ export function MediaQueue({ entity }) {
|
|
|
99
99
|
minHeight: 0,
|
|
100
100
|
listStyle: 'none',
|
|
101
101
|
overflowY: 'auto',
|
|
102
|
+
overflowX: 'hidden',
|
|
102
103
|
}), children: [_jsxs(AnimatePresence, { initial: false, children: [leading.map((item, index) => (_jsx(QueueRow, { item: item, still: still === true, dim: index < at || (finished && item.current === true), played: finished && item.current === true, onPlay: () => run('playQueueItem', item) }, item.id))), shown.map((item) => (_jsx(QueueRow, { item: item, still: still === true,
|
|
103
104
|
// While Clear works only what is on its way out is dimmed.
|
|
104
105
|
dim: clearing, reorderable: true, onPlay: () => run('playQueueItem', item), onRemove: () => run('removeQueueItem', item), onDrop: () => drop(item), onStep: (by) => step(item, by) }, item.id)))] }), hidden > 0 ? (_jsxs(Typography, { as: "li", variant: "secondary", color: "textMuted", py: 2, css: { flex: 'none' }, children: ["and ", hidden.toLocaleString(), " more"] })) : null] }))] }));
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** @jsxImportSource @emotion/react */
|
|
2
|
+
import type { EntityRef, MediaPlayerEntity } from '@hashsome/core';
|
|
3
|
+
/** What the pill says: how many speakers play together, or which stream is playing in another room, or nothing. */
|
|
4
|
+
export interface SpeakersSummary {
|
|
5
|
+
/** The speakers playing together with this player, itself included; 0 while it plays alone. */
|
|
6
|
+
together: number;
|
|
7
|
+
/** The streams playing on the speakers it could join, named by the player that leads each. Empty while it is in a group. */
|
|
8
|
+
elsewhere: string[];
|
|
9
|
+
}
|
|
10
|
+
/** The speakers a player could be grouped with, narrowed to an allowlist when there is one, and what they play. */
|
|
11
|
+
export declare function useSpeakersSummary(player: MediaPlayerEntity | undefined, allowed: readonly EntityRef[] | undefined): SpeakersSummary;
|
|
12
|
+
/** The words for a summary: "Speakers" with nothing going on, "3 speakers" in a group, "Patio playing" or "2 streams playing" when there is a stream to join. */
|
|
13
|
+
export declare function summaryLabel(summary: SpeakersSummary): string;
|
|
14
|
+
/**
|
|
15
|
+
* A small pill that says how a player's speakers stand and opens them: "Speakers" while it plays alone, "3 speakers"
|
|
16
|
+
* once others play with it, "Patio playing" when there is a stream in another room to join. A dot marks a stream to
|
|
17
|
+
* join, and the speaker icon takes the accent colour while the player is in a group.
|
|
18
|
+
*/
|
|
19
|
+
export declare function SpeakersPill({ player, speakers, onClick, pressed, compact, }: {
|
|
20
|
+
/** The player whose speakers they are. */
|
|
21
|
+
player: MediaPlayerEntity | undefined;
|
|
22
|
+
/** An allowlist of the speakers to count, as `MediaSpeakers` takes it. */
|
|
23
|
+
speakers?: readonly EntityRef[] | undefined;
|
|
24
|
+
onClick: () => void;
|
|
25
|
+
/** Whether what it opens is open, for a pill that toggles an inline view. */
|
|
26
|
+
pressed?: boolean | undefined;
|
|
27
|
+
/** Without words, for a bar with no room: the icon, and the count in a group. */
|
|
28
|
+
compact?: boolean;
|
|
29
|
+
}): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
30
|
+
/** The pill, opening the speakers in the side panel every media overlay uses, which can be made full size. It builds
|
|
31
|
+
* the speakers itself and is memoized: the panel keeps its content live by pushing it again whenever it is a new
|
|
32
|
+
* element, and a parent that re-renders when the panel opens (as the full player does) would otherwise build a new one
|
|
33
|
+
* each time, and push it again, without end. */
|
|
34
|
+
export declare const SpeakersOverlay: import("react").MemoExoticComponent<({ player, entity, name, title, allowed, compact, }: {
|
|
35
|
+
player: MediaPlayerEntity | undefined;
|
|
36
|
+
/** The player, as a ref. */
|
|
37
|
+
entity: EntityRef;
|
|
38
|
+
/** What to call it, when it is not the name it reports. */
|
|
39
|
+
name?: string | undefined;
|
|
40
|
+
/** The player's name, under the panel's heading. */
|
|
41
|
+
title: string;
|
|
42
|
+
allowed?: readonly EntityRef[] | undefined;
|
|
43
|
+
compact?: boolean;
|
|
44
|
+
}) => import("@emotion/react/jsx-runtime").JSX.Element>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
|
|
2
|
+
import { Box, Flex, Typography } from 'e-prim';
|
|
3
|
+
import { memo } from 'react';
|
|
4
|
+
import { useEntities } from '../hooks.js';
|
|
5
|
+
import { Icon } from '../icon.js';
|
|
6
|
+
import { DrawerTrigger } from '../layout/use-drawer.js';
|
|
7
|
+
import { MediaSpeakers, offeredSpeakers } from './media-speakers.js';
|
|
8
|
+
/** The speakers a player could be grouped with, narrowed to an allowlist when there is one, and what they play. */
|
|
9
|
+
export function useSpeakersSummary(player, allowed) {
|
|
10
|
+
const offered = offeredSpeakers(player?.groupable, allowed).filter((ref) => ref !== player?.ref);
|
|
11
|
+
const states = useEntities(offered);
|
|
12
|
+
const group = player?.group;
|
|
13
|
+
if (group) {
|
|
14
|
+
return { together: group.members.length + 1, elsewhere: [] };
|
|
15
|
+
}
|
|
16
|
+
// One stream per leader: two speakers playing together are one stream, named by who leads it.
|
|
17
|
+
const byRef = new Map();
|
|
18
|
+
states.forEach((state, index) => {
|
|
19
|
+
const ref = offered[index];
|
|
20
|
+
if (ref !== undefined && state?.kind === 'mediaPlayer') {
|
|
21
|
+
byRef.set(ref, state.name);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
const streams = new Map();
|
|
25
|
+
states.forEach((state) => {
|
|
26
|
+
if (state?.kind !== 'mediaPlayer' || state.availability !== 'ready') {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (state.playback !== 'playing') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const leader = state.group?.leader ?? state.ref;
|
|
33
|
+
if (!streams.has(leader)) {
|
|
34
|
+
streams.set(leader, byRef.get(leader) ?? state.name);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return { together: 0, elsewhere: [...streams.values()] };
|
|
38
|
+
}
|
|
39
|
+
/** The words for a summary: "Speakers" with nothing going on, "3 speakers" in a group, "Patio playing" or "2 streams playing" when there is a stream to join. */
|
|
40
|
+
export function summaryLabel(summary) {
|
|
41
|
+
if (summary.together > 1) {
|
|
42
|
+
return `${summary.together} speakers`;
|
|
43
|
+
}
|
|
44
|
+
const [only] = summary.elsewhere;
|
|
45
|
+
if (summary.elsewhere.length === 1 && only !== undefined) {
|
|
46
|
+
return `${only} playing`;
|
|
47
|
+
}
|
|
48
|
+
if (summary.elsewhere.length > 1) {
|
|
49
|
+
return `${summary.elsewhere.length} streams playing`;
|
|
50
|
+
}
|
|
51
|
+
return 'Speakers';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A small pill that says how a player's speakers stand and opens them: "Speakers" while it plays alone, "3 speakers"
|
|
55
|
+
* once others play with it, "Patio playing" when there is a stream in another room to join. A dot marks a stream to
|
|
56
|
+
* join, and the speaker icon takes the accent colour while the player is in a group.
|
|
57
|
+
*/
|
|
58
|
+
export function SpeakersPill({ player, speakers, onClick, pressed, compact = false, }) {
|
|
59
|
+
const summary = useSpeakersSummary(player, speakers);
|
|
60
|
+
const label = summaryLabel(summary);
|
|
61
|
+
const grouped = summary.together > 1;
|
|
62
|
+
const offering = !grouped && summary.elsewhere.length > 0;
|
|
63
|
+
const words = compact ? (grouped ? String(summary.together) : undefined) : label;
|
|
64
|
+
return (_jsxs(Flex, { as: "button", type: "button", "aria-label": `Speakers: ${label}`, "aria-haspopup": "dialog", "aria-expanded": pressed, title: label, onClick: onClick, align: "center", justify: "center", gap: 2, radius: "full", height: 30, px: words === undefined ? 0 : 3, background: "surfaceRaised", color: grouped ? 'text' : 'textMuted', cursor: "pointer", css: { flex: 'none', minWidth: 30, maxWidth: '100%', border: 0 }, children: [_jsx(Box, { color: grouped ? 'accent' : 'textMuted', css: { display: 'grid' }, children: _jsx(Icon, { name: "lu:speaker", size: 14 }) }), offering ? (_jsx(Box, { background: "accent", radius: "full", width: 7, height: 7, "aria-hidden": "true", css: { flex: 'none' } })) : null, words !== undefined ? (_jsx(Typography, { as: "span", variant: "label", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: words })) : null] }));
|
|
65
|
+
}
|
|
66
|
+
/** The pill, opening the speakers in the side panel every media overlay uses, which can be made full size. It builds
|
|
67
|
+
* the speakers itself and is memoized: the panel keeps its content live by pushing it again whenever it is a new
|
|
68
|
+
* element, and a parent that re-renders when the panel opens (as the full player does) would otherwise build a new one
|
|
69
|
+
* each time, and push it again, without end. */
|
|
70
|
+
export const SpeakersOverlay = memo(function SpeakersOverlay({ player, entity, name, title, allowed, compact, }) {
|
|
71
|
+
const body = (_jsx(MediaSpeakers, { entity: entity, ...(name !== undefined ? { name } : {}), ...(allowed ? { speakers: [...allowed] } : {}) }));
|
|
72
|
+
return (_jsx(DrawerTrigger, { icon: "lu:speaker", label: "Speakers", kind: title, body: body, children: (open) => (_jsx(SpeakersPill, { player: player, speakers: allowed, onClick: open, ...(compact ? { compact } : {}) })) }));
|
|
73
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** @jsxImportSource @emotion/react */
|
|
2
|
+
import type { EntityRef } from '@hashsome/core';
|
|
3
|
+
import { type EntityHandle } from '../entity-handle.ts';
|
|
4
|
+
export interface MediaSpeakersProps {
|
|
5
|
+
/** The player whose stream this is, as a ref like `ma:porch` or a handle (a custom source). */
|
|
6
|
+
entity: EntityRef | EntityHandle<'mediaPlayer'>;
|
|
7
|
+
/** What to call the player. Defaults to the player's own name. */
|
|
8
|
+
name?: string;
|
|
9
|
+
/** An allowlist of the speakers that can be added to this stream (or joined). Only those that are in it and that the player can be grouped with are offered; a speaker the player cannot be grouped with is left out, and one that is but is unavailable shows dimmed. Absent, every speaker it can be grouped with is offered. Speakers already in the stream are always shown, so they can be taken out. */
|
|
10
|
+
speakers?: EntityRef[];
|
|
11
|
+
}
|
|
12
|
+
/** The speakers to offer: the ones the player can be grouped with, narrowed to an allowlist when there is one. */
|
|
13
|
+
export declare function offeredSpeakers(groupable: readonly EntityRef[] | undefined, allowed: readonly EntityRef[] | undefined): EntityRef[];
|
|
14
|
+
/**
|
|
15
|
+
* The speakers of a player's stream: who plays together with it, a volume for each, a way to take one out, to add
|
|
16
|
+
* another, to join a stream that is playing in another room, and to put the stream back to just this player.
|
|
17
|
+
* Everything is asked of the backend and shown when it answers. Playing something else on a speaker, or on this
|
|
18
|
+
* player, to do it asks first.
|
|
19
|
+
*/
|
|
20
|
+
export declare function MediaSpeakers({ entity, name, speakers }: MediaSpeakersProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
|
|
2
|
+
import { Flex, Typography } from 'e-prim';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { fallbackName } from '../entity-handle.js';
|
|
5
|
+
import { useEntities, useEntityHandle } from '../hooks.js';
|
|
6
|
+
import { Icon } from '../icon.js';
|
|
7
|
+
import { ConfirmDialog } from '../layout/confirm-dialog.js';
|
|
8
|
+
import { Cover } from '../layout/cover.js';
|
|
9
|
+
import { ValueBar } from '../layout/drawer-controls.js';
|
|
10
|
+
import { Skeleton } from '../layout/skeleton.js';
|
|
11
|
+
import { Spinner } from '../layout/spinner.js';
|
|
12
|
+
import { useClient } from '../provider.js';
|
|
13
|
+
import { statusLabels } from '../status.js';
|
|
14
|
+
import { usePressFeedback, useVolumeControl } from './media-controls.js';
|
|
15
|
+
/** The speakers to offer: the ones the player can be grouped with, narrowed to an allowlist when there is one. */
|
|
16
|
+
export function offeredSpeakers(groupable, allowed) {
|
|
17
|
+
const can = groupable ?? [];
|
|
18
|
+
return allowed ? can.filter((ref) => allowed.includes(ref)) : [...can];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The speakers of a player's stream: who plays together with it, a volume for each, a way to take one out, to add
|
|
22
|
+
* another, to join a stream that is playing in another room, and to put the stream back to just this player.
|
|
23
|
+
* Everything is asked of the backend and shown when it answers. Playing something else on a speaker, or on this
|
|
24
|
+
* player, to do it asks first.
|
|
25
|
+
*/
|
|
26
|
+
export function MediaSpeakers({ entity, name, speakers }) {
|
|
27
|
+
const handle = useEntityHandle('mediaPlayer', entity);
|
|
28
|
+
const client = useClient();
|
|
29
|
+
const player = handle.entity;
|
|
30
|
+
const me = player?.ref;
|
|
31
|
+
const { feedbackFor, press } = usePressFeedback(me);
|
|
32
|
+
const [ask, setAsk] = useState(null);
|
|
33
|
+
const fallback = fallbackName(entity);
|
|
34
|
+
const own = name ?? player?.name ?? fallback;
|
|
35
|
+
// What the speakers on offer play, to tell the ones with a stream of their own from the quiet ones.
|
|
36
|
+
const offered = offeredSpeakers(player?.groupable, speakers).filter((ref) => ref !== me);
|
|
37
|
+
const offeredStates = useEntities(offered);
|
|
38
|
+
if (handle.status !== 'ready' || !player || !me) {
|
|
39
|
+
return (_jsx(Flex, { direction: "column", gap: 2, "aria-busy": "true", children: handle.status === 'ready' || handle.status === 'loading' ? ([0, 1, 2].map((row) => _jsx(Skeleton, { width: "100%", height: 44, radius: "row" }, row))) : (_jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: statusLabels[handle.status] })) }));
|
|
40
|
+
}
|
|
41
|
+
if (!player.capabilities.group) {
|
|
42
|
+
return (_jsxs(Typography, { as: "p", variant: "body", color: "textMuted", children: [own, " cannot be grouped with other speakers."] }));
|
|
43
|
+
}
|
|
44
|
+
const group = player.group;
|
|
45
|
+
const follows = group !== undefined && group.leader !== me;
|
|
46
|
+
// Where the changes to the stream go: to the player it belongs to, which is this one unless it follows.
|
|
47
|
+
const head = group?.leader ?? me;
|
|
48
|
+
const inStream = group ? (follows ? [group.leader, ...group.members] : group.members) : [];
|
|
49
|
+
const others = inStream.filter((ref) => ref !== me);
|
|
50
|
+
const candidates = offered.filter((ref) => !others.includes(ref));
|
|
51
|
+
const playsOwn = (ref) => {
|
|
52
|
+
const state = offeredStates[offered.indexOf(ref)];
|
|
53
|
+
return (state?.kind === 'mediaPlayer' &&
|
|
54
|
+
state.availability === 'ready' &&
|
|
55
|
+
(state.playback === 'playing' || state.group !== undefined));
|
|
56
|
+
};
|
|
57
|
+
const elsewhere = candidates.filter(playsOwn);
|
|
58
|
+
const quiet = candidates.filter((ref) => !playsOwn(ref));
|
|
59
|
+
const send = (target, command, args) => client.command(target, command, args);
|
|
60
|
+
const reset = () => press('group:reset', () => follows
|
|
61
|
+
? send(me, 'leaveGroup')
|
|
62
|
+
: send(me, 'setGroupMembers', { remove: group?.members ?? [] }));
|
|
63
|
+
const addHere = (speaker) => {
|
|
64
|
+
const go = () => press(`group:add:${speaker.ref}`, () => send(head, 'setGroupMembers', { add: [speaker.ref] }));
|
|
65
|
+
if (speaker.playing === undefined) {
|
|
66
|
+
go();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
setAsk({
|
|
70
|
+
title: `Play this on ${speaker.name}?`,
|
|
71
|
+
text: `${speaker.name} is playing ${speaker.playing}. It will play this stream instead.`,
|
|
72
|
+
go,
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
const join = (speaker) => {
|
|
76
|
+
const go = () => press(`group:join:${speaker.ref}`, () => send(speaker.leader ?? speaker.ref, 'setGroupMembers', { add: [me] }));
|
|
77
|
+
if (player.playback !== 'playing') {
|
|
78
|
+
go();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
setAsk({
|
|
82
|
+
title: `Join ${speaker.name}'s stream?`,
|
|
83
|
+
text: `${own} will stop what it plays and play ${speaker.name}'s instead.`,
|
|
84
|
+
go,
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
return (_jsxs(Flex, { direction: "column", gap: 4, grow: 1, minHeight: 0, css: { overflowY: 'auto', overflowX: 'hidden' }, children: [!follows && others.length > 1 ? (_jsx(Flex, { justify: "flex-end", children: _jsx(PillButton, { label: "Remove all", icon: "lu:x", description: `Take everyone else out of this stream, back to just ${own}`, state: feedbackFor('group:reset'), onClick: reset }) })) : null, _jsxs(Section, { title: "This stream", children: [_jsx(SpeakerRow, { speaker: me, label: own, volume: true, handle: handle, children: () => follows ? (_jsx(PillButton, { label: "Leave", icon: "lu:log-out", description: `Take ${own} out of this stream`, state: feedbackFor('group:reset'), onClick: reset })) : null }), others.map((ref) => (_jsx(SpeakerRow, { speaker: ref, volume: true, note: ref === group?.leader ? 'Leading' : undefined, children: (speaker) => ref === group?.leader ? (_jsx(PillButton, { label: "Remove", icon: "lu:x", description: `Take ${speaker.name} out of this stream and carry on here`, state: feedbackFor('group:takeover'), onClick: () => press('group:takeover', () => send(me, 'takeOverGroup')) })) : (_jsx(PillButton, { label: "Remove", icon: "lu:x", description: `Take ${speaker.name} out of this stream`, state: feedbackFor(`group:remove:${ref}`), onClick: () => press(`group:remove:${ref}`, () => send(head, 'setGroupMembers', { remove: [ref] })) })) }, ref)))] }), elsewhere.length > 0 ? (_jsx(Section, { title: "Playing elsewhere", children: elsewhere.map((ref) => (_jsx(SpeakerRow, { speaker: ref, children: (speaker) => (_jsxs(_Fragment, { children: [_jsx(PillButton, { label: "Join", icon: "lu:log-in", description: `Join ${speaker.name}'s stream`, disabled: !speaker.available, state: feedbackFor(`group:join:${ref}`), onClick: () => join(speaker) }), _jsx(PillButton, { label: "Add", icon: "lu:plus", description: `Add ${speaker.name} to this stream`, disabled: !speaker.available, state: feedbackFor(`group:add:${ref}`), onClick: () => addHere(speaker) })] })) }, ref))) })) : null, _jsx(Section, { title: "Add to this stream", children: quiet.length === 0 ? (_jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "No other speakers to add." })) : (quiet.map((ref) => (_jsx(SpeakerRow, { speaker: ref, children: (speaker) => (_jsx(PillButton, { label: "Add", icon: "lu:plus", description: `Add ${speaker.name} to this stream`, disabled: !speaker.available, state: feedbackFor(`group:add:${ref}`), onClick: () => addHere(speaker) })) }, ref)))) }), _jsx(ConfirmDialog, { open: ask !== null, title: ask?.title ?? '', description: ask?.text, confirmLabel: "Go ahead", onConfirm: () => {
|
|
88
|
+
ask?.go();
|
|
89
|
+
setAsk(null);
|
|
90
|
+
}, onCancel: () => setAsk(null) })] }));
|
|
91
|
+
}
|
|
92
|
+
function Section({ title, children }) {
|
|
93
|
+
return (_jsxs(Flex, { direction: "column", gap: 1, children: [_jsx(Typography, { as: "h4", variant: "eyebrow", uppercase: true, color: "textMuted", children: title }), children] }));
|
|
94
|
+
}
|
|
95
|
+
/** One speaker: its picture, name and what it is doing, and whatever its actions are. With `volume` (a speaker already
|
|
96
|
+
* in the stream) it is just its name, a note like "Leading", its actions, and a volume bar under them: what it plays
|
|
97
|
+
* is the stream's, so neither the picture nor the title is shown again. */
|
|
98
|
+
function SpeakerRow({ speaker, label, volume = false, note, handle: given, children, }) {
|
|
99
|
+
const own = useEntityHandle('mediaPlayer', given ?? speaker);
|
|
100
|
+
const player = own.entity;
|
|
101
|
+
const level = useVolumeControl(own);
|
|
102
|
+
const available = own.status === 'ready';
|
|
103
|
+
const title = player?.media?.title;
|
|
104
|
+
const info = {
|
|
105
|
+
ref: speaker,
|
|
106
|
+
name: label ?? player?.name ?? fallbackName(speaker),
|
|
107
|
+
available,
|
|
108
|
+
playing: available && player?.playback === 'playing' ? (title ?? 'something') : undefined,
|
|
109
|
+
leader: player?.group?.leader === speaker ? undefined : player?.group?.leader,
|
|
110
|
+
};
|
|
111
|
+
const status = !available
|
|
112
|
+
? statusLabels[own.status]
|
|
113
|
+
: [
|
|
114
|
+
info.playing !== undefined
|
|
115
|
+
? `Playing ${info.playing}`
|
|
116
|
+
: player?.playback === 'paused'
|
|
117
|
+
? 'Paused'
|
|
118
|
+
: 'Idle',
|
|
119
|
+
note,
|
|
120
|
+
]
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.join(' · ');
|
|
123
|
+
if (volume) {
|
|
124
|
+
return (_jsxs(Flex, { direction: "column", gap: 2, py: 2, css: { opacity: available ? 1 : 0.55 }, children: [_jsxs(Flex, { align: "center", gap: 3, children: [_jsxs(Flex, { align: "baseline", gap: 2, grow: 1, minWidth: 0, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: info.name }), !available || note ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", css: { flex: 'none' }, children: available ? note : status })) : null] }), _jsx(Flex, { align: "center", gap: 2, css: { flex: 'none' }, children: children(info) })] }), available && player?.capabilities.volume ? (_jsxs(Flex, { align: "center", gap: 2, children: [_jsx(ValueBar, { label: `${info.name} volume`, value: level.shown, min: 0, max: 100, keyStep: 5, height: 10, onDrag: level.drag, onCommit: level.commit }), _jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", minWidth: 32, align: "right", css: { flex: 'none', fontVariantNumeric: 'tabular-nums' }, children: [level.shown, "%"] })] })) : null] }));
|
|
125
|
+
}
|
|
126
|
+
return (_jsxs(Flex, { align: "center", gap: 3, py: 1.5, css: { opacity: available ? 1 : 0.55 }, children: [_jsx(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: "textMuted", radius: "small", width: 40, height: 40, overflow: "hidden", css: { flex: 'none' }, children: player?.media?.artworkUrl && info.playing !== undefined ? (_jsx(Cover, { src: player.media.artworkUrl })) : (_jsx(Icon, { name: "lu:speaker", size: 18 })) }), _jsxs(Flex, { direction: "column", grow: 1, minWidth: 0, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: info.name }), _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: status })] }), _jsx(Flex, { align: "center", gap: 2, css: { flex: 'none' }, children: children(info) })] }));
|
|
127
|
+
}
|
|
128
|
+
/** A small text button for what a speaker does in the stream: "Leave", "Join" or "Add", with a small icon and the
|
|
129
|
+
* pending ring or the error colour the press asks for. Its full sentence is the accessible name. */
|
|
130
|
+
function PillButton({ label, icon, description, state, disabled = false, onClick, }) {
|
|
131
|
+
const busy = state === 'pending';
|
|
132
|
+
return (_jsxs(Flex, { as: "button", type: "button", "aria-label": description ?? label, title: description ?? label, onClick: onClick, disabled: disabled || busy, align: "center", justify: "center", gap: 1.5, radius: "full", height: 32, px: 3.5, background: "surfaceRaised", color: state === 'error' ? 'danger' : 'text', cursor: disabled ? 'default' : busy ? 'progress' : 'pointer', css: { flex: 'none', border: 0, opacity: disabled ? 0.5 : 1 }, children: [busy ? _jsx(Spinner, { size: 14 }) : _jsx(Icon, { name: icon, size: 14 }), _jsx(Typography, { as: "span", variant: "label", children: label })] }));
|
|
133
|
+
}
|