@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.
@@ -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' ? 15_000 : kind === 'next' ? 10_000 : kind === 'previous' ? 4_000 : 2_000;
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 = { entry, answered: false, baseTitle: lastSeen.get(ref)?.title };
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 = !changesTrack(waiting.entry.kind) || (seen.title !== waiting.baseTitle && seen.started);
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 tabs = [
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
+ }