@hashsome/ui 0.10.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.
@@ -18,6 +18,18 @@ import { useMediaBrowser } from '../use-media-browser.js';
18
18
  import { trackMedia, useMediaPending } from './media-pending.js';
19
19
  /** The height of the search field, and of the row of tabs it replaces. */
20
20
  const SEARCH_HEIGHT = 36;
21
+ /** The name of each kind of thing a search finds, on the chip that narrows the results to it. */
22
+ const KIND_LABELS = {
23
+ album: 'Albums',
24
+ artist: 'Artists',
25
+ track: 'Tracks',
26
+ playlist: 'Playlists',
27
+ radio: 'Radio',
28
+ folder: 'Folders',
29
+ other: 'Other',
30
+ };
31
+ /** The chip for every kind at once. */
32
+ const ALL_KINDS = 'all';
21
33
  const KIND_ICONS = {
22
34
  folder: 'lu:folder',
23
35
  artist: 'lu:mic-vocal',
@@ -95,9 +107,23 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
95
107
  }
96
108
  },
97
109
  }
98
- : {}), css: { background: 'none', outline: 'none' } }), onDemand ? (_jsx(PlainButton, { "aria-label": "Close search", title: "Close search", onClick: closeSearch, center: true, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : browser.query !== '' ? (_jsx(PlainButton, { "aria-label": "Clear search", title: "Clear search", onClick: () => browser.search(''), center: true, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : null] })) : null;
99
- /** The categories (and, with `search='icon'`, the search icon at the end). */
100
- const tabsRow = browser.tabs ? (_jsxs(Flex, { align: "center", gap: 3, ...(onDemand ? { minHeight: SEARCH_HEIGHT } : {}), children: [_jsx(Flex, { direction: "column", grow: 1, minWidth: 0, children: _jsx(ChipRow, { tabs: true, options: browser.tabs.map((item) =>
110
+ : {}), css: {
111
+ background: 'none',
112
+ outline: 'none',
113
+ // A search field gets a clear button of the browser's own in some browsers, and this one has its own
114
+ // (and a button to close it), so the browser's is not drawn.
115
+ '&::-webkit-search-cancel-button, &::-webkit-search-decoration': {
116
+ WebkitAppearance: 'none',
117
+ appearance: 'none',
118
+ display: 'none',
119
+ },
120
+ } }), onDemand ? (_jsx(PlainButton, { "aria-label": "Close search", title: "Close search", onClick: closeSearch, center: true, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : browser.query !== '' ? (_jsx(PlainButton, { "aria-label": "Clear search", title: "Clear search", onClick: () => browser.search(''), center: true, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : null] })) : null;
121
+ /** The categories (and, with `search='icon'`, the search icon at the end). While a search has results, the
122
+ * same row is the kinds to narrow it to: the shelves mean nothing to a search's results, which belong to none. */
123
+ const tabsRow = browser.tabs ? (_jsxs(Flex, { align: "center", gap: 3, ...(onDemand ? { minHeight: SEARCH_HEIGHT } : {}), children: [_jsx(Flex, { direction: "column", grow: 1, minWidth: 0, children: browser.searching && browser.kindsPending ? (_jsx(KindsSkeleton, {})) : browser.searching && browser.kinds ? (_jsx(ChipRow, { tabs: true, options: [
124
+ { value: ALL_KINDS, label: 'All' },
125
+ ...browser.kinds.map((kind) => ({ value: kind, label: KIND_LABELS[kind] })),
126
+ ], value: browser.kind ?? ALL_KINDS, onChange: (id) => browser.filterBy(id === ALL_KINDS ? undefined : id) })) : (_jsx(ChipRow, { tabs: true, options: browser.tabs.map((item) =>
101
127
  // Inside a shelf the selected tab becomes the way back, so nothing is added to the
102
128
  // layout and nothing below it moves.
103
129
  item.id === browser.activeTab && inside
@@ -107,11 +133,15 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
107
133
  icon: 'lu:arrow-left',
108
134
  ariaLabel: 'Back',
109
135
  }
110
- : { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) }) }), actionsHere, searchTrigger] })) : null;
111
- 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}`))) }))] }));
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', 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}`))) }))] }));
112
138
  }
113
139
  /** What shows where the list will be while it loads: placeholders shaped like what is coming (cards in
114
140
  * the theater layout, rows in the list), in the room the real thing takes, so nothing moves. */
141
+ /** Where the kinds to narrow a search to will be, in the chips' own height, so the list below does not move. */
142
+ function KindsSkeleton() {
143
+ return (_jsx(Flex, { gap: 2, "aria-busy": "true", css: { height: CHIP_HEIGHT }, children: [40, 78, 74, 70, 62].map((width) => (_jsx(Skeleton, { width: width, height: CHIP_HEIGHT, radius: "full" }, width))) }));
144
+ }
115
145
  function BrowseSkeleton({ theater }) {
116
146
  return theater ? (_jsx(Flex, { as: "ul", role: "status", "aria-label": "Loading", "aria-busy": "true", gap: 4, m: 0, p: 0, pb: 2, css: { ...THEATER_STAGE, listStyle: 'none', overflow: 'hidden' }, children: Array.from({ length: 12 }, (_, index) => (_jsxs(Flex, { as: "li", direction: "column", gap: 2, css: { flex: 'none', width: CARD_SIZE }, children: [_jsx(Skeleton, { width: CARD_SIZE, height: CARD_SIZE, radius: "card" }), _jsxs(Flex, { direction: "column", gap: 1, px: 1, children: [_jsx(Skeleton, { width: "70%", height: 14 }), _jsx(Skeleton, { width: "45%", height: 12 })] })] }, index))) })) : (_jsx(Flex, { as: "ul", role: "status", "aria-label": "Loading", "aria-busy": "true", direction: "column", gap: 1, m: 0, p: 0, css: { listStyle: 'none' }, children: Array.from({ length: 6 }, (_, index) => (_jsxs(Flex, { as: "li", align: "center", gap: 3, py: 1.5, px: 2, children: [_jsx(Skeleton, { width: 44, height: 44 }), _jsxs(Flex, { direction: "column", gap: 1.5, grow: 1, children: [_jsx(Skeleton, { width: "60%", height: 14 }), _jsx(Skeleton, { width: "35%", height: 12 })] })] }, index))) }));
117
147
  }
@@ -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;