@hashsome/ui 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/entities/media-browser.js +24 -9
- package/dist/entities/media-controls.d.ts +22 -4
- package/dist/entities/media-controls.js +45 -10
- package/dist/entities/media-loading-line.d.ts +9 -0
- package/dist/entities/media-loading-line.js +14 -0
- package/dist/entities/media-pending.d.ts +24 -0
- package/dist/entities/media-pending.js +121 -0
- package/dist/entities/media-player-bar.js +10 -6
- package/dist/entities/media-progress.d.ts +4 -0
- package/dist/entities/media-progress.js +41 -0
- package/dist/entities/now-playing.js +9 -6
- package/dist/layout/drawer-controls.js +46 -3
- package/dist/layout/fade-scroll.d.ts +2 -0
- package/dist/layout/fade-scroll.js +1 -1
- package/dist/layout/icon-button-styles.js +3 -1
- package/dist/layout/spinner.d.ts +13 -0
- package/dist/layout/spinner.js +28 -0
- package/dist/layout/tile.d.ts +1 -1
- package/dist/layout/tile.js +3 -1
- package/dist/use-media-queue.js +5 -2
- package/package.json +2 -2
|
@@ -10,10 +10,12 @@ import { CHIP_HEIGHT, ChipRow } from '../layout/drawer-controls.js';
|
|
|
10
10
|
import { FadeScroll } from '../layout/fade-scroll.js';
|
|
11
11
|
import { MarqueeText } from '../layout/marquee-text.js';
|
|
12
12
|
import { PlainButton } from '../layout/plain-button.js';
|
|
13
|
+
import { Spinner } from '../layout/spinner.js';
|
|
13
14
|
import { Reveal } from '../layout/reveal.js';
|
|
14
15
|
import { Skeleton } from '../layout/skeleton.js';
|
|
15
16
|
import { IconButton } from '../layout/tile.js';
|
|
16
17
|
import { useMediaBrowser } from '../use-media-browser.js';
|
|
18
|
+
import { trackMedia, useMediaPending } from './media-pending.js';
|
|
17
19
|
/** The height of the search field, and of the row of tabs it replaces. */
|
|
18
20
|
const SEARCH_HEIGHT = 36;
|
|
19
21
|
const KIND_ICONS = {
|
|
@@ -39,6 +41,11 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
|
|
|
39
41
|
const player = handle.entity;
|
|
40
42
|
// A player with a queue can be told to add to it, play next and shuffle; one without can only play.
|
|
41
43
|
const queueable = player?.capabilities.queue === true;
|
|
44
|
+
/** Asking the player to play something can take it many seconds; until it answers, the item and the
|
|
45
|
+
* player say so. */
|
|
46
|
+
const waiting = useMediaPending(entity);
|
|
47
|
+
const ask = (kind, item, args) => trackMedia(entity, { kind, item: item.id, label: item.title }, handle.command('playMedia', args));
|
|
48
|
+
const askedOf = (item) => waiting.filter((entry) => entry.item === item.id).map((entry) => entry.kind);
|
|
42
49
|
/** What the actions do. Playing replaces the queue, as in Spotify: a track in an album or playlist
|
|
43
50
|
* queues all of it and starts there, so Next goes to the next track of the album. */
|
|
44
51
|
const play = (item) => {
|
|
@@ -46,17 +53,17 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
|
|
|
46
53
|
const within = item.kind === 'track' && container && ['album', 'playlist'].includes(container.kind)
|
|
47
54
|
? container
|
|
48
55
|
: undefined;
|
|
49
|
-
|
|
56
|
+
ask('playItem', item, {
|
|
50
57
|
item: item.id,
|
|
51
58
|
mode: 'replace',
|
|
52
59
|
...(within ? { context: within.id } : {}),
|
|
53
60
|
});
|
|
54
61
|
onPlay?.(item);
|
|
55
62
|
};
|
|
56
|
-
const playNext = (item) =>
|
|
57
|
-
const addToQueue = (item) =>
|
|
63
|
+
const playNext = (item) => ask('queueNext', item, { item: item.id, mode: 'next' });
|
|
64
|
+
const addToQueue = (item) => ask('queueAdd', item, { item: item.id, mode: 'add' });
|
|
58
65
|
const shuffleAll = (item) => {
|
|
59
|
-
|
|
66
|
+
ask('playItem', item, { item: item.id, mode: 'replace', shuffle: true });
|
|
60
67
|
onPlay?.(item);
|
|
61
68
|
};
|
|
62
69
|
/** Something is open inside the selected shelf (not a search): the tab shows the way back. */
|
|
@@ -101,7 +108,7 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
|
|
|
101
108
|
ariaLabel: 'Back',
|
|
102
109
|
}
|
|
103
110
|
: { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) }) }), actionsHere, searchTrigger] })) : null;
|
|
104
|
-
return (_jsxs(Flex, { direction: "column", gap: 3, minHeight: 0, position: "relative", children: [onDemand ? (_jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(motion.div, { initial: { opacity: 0, x: fieldShown ? 28 : 0 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: fieldShown ? 0 : 28 }, transition: { duration: 0.18, ease: 'easeOut' }, children: fieldShown ? search : browser.tabs === undefined ? header : tabsRow }, fieldShown ? 'field' : 'row') })) : (_jsxs(_Fragment, { children: [browser.tabs === undefined ? header : null, search, tabsRow] })), browser.error ? (_jsx(Message, { theater: theater, children: _jsx(Typography, { as: "p", variant: "body", color: "danger", role: "alert", m: 0, children: browser.error }) })) : browser.loading ? (_jsx(BrowseSkeleton, { theater: theater })) : browser.items.length === 0 ? (_jsx(Message, { theater: theater, children: _jsx(Typography, { as: "p", variant: "body", color: "textMuted", m: 0, children: browser.searching ? 'Nothing found.' : 'Nothing here.' }) })) : theater ? (_jsx(FadeScroll, { as: "ul", gap: 4, m: 0, p: 0, pb: 2, css: { ...THEATER_STAGE, listStyle: 'none', scrollSnapType: 'x proximity' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none', scrollSnapAlign: 'start' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseCard, { item: item, queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) })) : (_jsx(Flex, { direction: "column", gap: 1, as: "ul", m: 0, p: 0, minHeight: 0, css: { listStyle: 'none', overflowY: 'auto' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseRow, { item: item, queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onNext: () => playNext(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) }))] }));
|
|
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}`))) }))] }));
|
|
105
112
|
}
|
|
106
113
|
/** What shows where the list will be while it loads: placeholders shaped like what is coming (cards in
|
|
107
114
|
* the theater layout, rows in the list), in the room the real thing takes, so nothing moves. */
|
|
@@ -118,11 +125,11 @@ function PillButton({ icon, label, onClick, primary, compact, }) {
|
|
|
118
125
|
// Built like a tab (a button with the same padding), so the row they share does not change height.
|
|
119
126
|
return (_jsxs(Flex, { as: "button", type: "button", onClick: onClick, "aria-label": label, title: label, align: "center", justify: "center", gap: 1.5, height: CHIP_HEIGHT, px: 3, radius: "full", cursor: "pointer", background: primary ? 'accent' : 'surfaceRaised', color: primary ? 'accentText' : 'text', css: { flex: 'none', whiteSpace: 'nowrap' }, children: [_jsx(Icon, { name: icon, size: 14 }), compact ? null : (_jsx(Typography, { as: "span", variant: "body", children: label }))] }));
|
|
120
127
|
}
|
|
121
|
-
function BrowseRow({ item, queueable, onOpen, onPlay, onNext, onAdd, }) {
|
|
128
|
+
function BrowseRow({ item, asked, queueable, onOpen, onPlay, onNext, onAdd, }) {
|
|
122
129
|
// A row opens what can be opened and otherwise plays; an openable thing that can also be played
|
|
123
130
|
// (an album) gets its own play button.
|
|
124
131
|
const main = item.expandable ? onOpen : item.playable ? onPlay : undefined;
|
|
125
|
-
return (_jsxs(Flex, { align: "center", gap: 2, children: [_jsxs(PlainButton, { align: "center", gap: 3, grow: 1, minWidth: 0, radius: "row", py: 1.5, px: 2, cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, css: ({ palette }) => ({ '&:hover:not(:disabled)': { background: palette.surfaceRaised } }), children: [
|
|
132
|
+
return (_jsxs(Flex, { align: "center", gap: 2, children: [_jsxs(PlainButton, { align: "center", gap: 3, grow: 1, minWidth: 0, radius: "row", py: 1.5, px: 2, cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, css: ({ palette }) => ({ '&:hover:not(:disabled)': { background: palette.surfaceRaised } }), children: [_jsxs(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: "textMuted", radius: "small", width: 44, height: 44, overflow: "hidden", position: "relative", css: { flex: 'none' }, children: [item.artworkUrl ? (_jsx(Cover, { src: item.artworkUrl })) : (_jsx(Icon, { name: KIND_ICONS[item.kind], size: 20 })), asked.includes('playItem') ? _jsx(Waiting, { size: 20 }) : null] }), _jsxs(Flex, { direction: "column", minWidth: 0, grow: 1, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", children: _jsx(MarqueeText, { children: item.title }) }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: _jsx(MarqueeText, { children: item.subtitle }) })) : null] }), item.expandable ? _jsx(Opens, {}) : null] }), queueable && item.playable ? (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: "lu:list-start", label: `Play ${item.title} next`, glyph: 16, disabled: asked.includes('queueNext'), feedback: asked.includes('queueNext') ? 'pending' : undefined, onClick: onNext }), _jsx(IconButton, { icon: "lu:list-plus", label: `Add ${item.title} to the queue`, glyph: 16, disabled: asked.includes('queueAdd'), feedback: asked.includes('queueAdd') ? 'pending' : undefined, onClick: onAdd })] })) : null, item.expandable && item.playable ? (_jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, disabled: asked.includes('playItem'), feedback: asked.includes('playItem') ? 'pending' : undefined, onClick: onPlay })) : null] }));
|
|
126
133
|
}
|
|
127
134
|
/** The room the theater row takes, whatever it holds: it asks for full-size cards and gives way when
|
|
128
135
|
* there is less, and is a size container so the cards can shrink to fit. What stands in for the row
|
|
@@ -143,7 +150,15 @@ const CARD_SIZE = `min(${CARD}px, max(${MIN_CARD}px, calc(100cqh - ${LABEL}px)))
|
|
|
143
150
|
/** An item as a large card for the theater layout: big artwork (round for an artist), then the
|
|
144
151
|
* title and subtitle. It opens or plays like a row does, and an album or playlist gets its play
|
|
145
152
|
* button over the artwork's corner. */
|
|
146
|
-
function BrowseCard({ item, queueable, onOpen, onPlay, onAdd, }) {
|
|
153
|
+
function BrowseCard({ item, asked, queueable, onOpen, onPlay, onAdd, }) {
|
|
147
154
|
const main = item.expandable ? onOpen : item.playable ? onPlay : undefined;
|
|
148
|
-
return (_jsxs(Box, { position: "relative", css: { width: CARD_SIZE }, children: [_jsxs(PlainButton, { direction: "column", gap: 2, width: "100%", cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, children: [
|
|
155
|
+
return (_jsxs(Box, { position: "relative", css: { width: CARD_SIZE }, children: [_jsxs(PlainButton, { direction: "column", gap: 2, width: "100%", cursor: main ? 'pointer' : 'default', disabled: !main, "aria-label": item.expandable ? `Open ${item.title}` : `Play ${item.title}`, onClick: main, children: [_jsxs(Flex, { align: "center", justify: "center", background: "surfaceRaised", color: "textMuted", radius: item.kind === 'artist' ? 'full' : 'card', overflow: "hidden", position: "relative", css: { width: CARD_SIZE, height: CARD_SIZE }, children: [item.artworkUrl ? (_jsx(Cover, { src: item.artworkUrl })) : (_jsx(Icon, { name: KIND_ICONS[item.kind], size: 56 })), asked.includes('playItem') ? _jsx(Waiting, { size: 32 }) : null] }), _jsxs(Flex, { direction: "column", minWidth: 0, px: 1, children: [_jsxs(Flex, { align: "center", gap: 1, minWidth: 0, children: [_jsx(Typography, { as: "span", variant: "bodyStrong", css: { minWidth: 0 }, children: _jsx(MarqueeText, { children: item.title }) }), item.expandable ? _jsx(Opens, {}) : null] }), item.subtitle ? (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: _jsx(MarqueeText, { children: item.subtitle }) })) : null] })] }), item.playable && (queueable || item.expandable) ? (_jsxs(Flex, { align: "center", gap: 1, position: "absolute", css: { right: 10, top: `calc(${CARD_SIZE} - 54px)` }, children: [queueable ? (_jsx(IconButton, { icon: "lu:list-plus", label: `Add ${item.title} to the queue`, glyph: 16, disabled: asked.includes('queueAdd'), feedback: asked.includes('queueAdd') ? 'pending' : undefined, onClick: onAdd })) : null, item.expandable ? (_jsx(IconButton, { icon: "lu:play", label: `Play ${item.title}`, glyph: 16, disabled: asked.includes('playItem'), feedback: asked.includes('playItem') ? 'pending' : undefined, onClick: onPlay })) : null] })) : null] }));
|
|
156
|
+
}
|
|
157
|
+
/** Over an item's artwork while the player has not answered a request to play it. */
|
|
158
|
+
function Waiting({ size }) {
|
|
159
|
+
return (_jsx(Flex, { align: "center", justify: "center", position: "absolute", css: { inset: 0, background: 'rgba(0, 0, 0, 0.5)', color: 'white' }, children: _jsx(Spinner, { size: size }) }));
|
|
160
|
+
}
|
|
161
|
+
/** Marks something that opens (an album, a playlist, an artist, a folder) and is not played by a tap, as against a track. */
|
|
162
|
+
function Opens() {
|
|
163
|
+
return (_jsx(Box, { color: "textMuted", css: { flex: 'none', display: 'grid' }, "aria-hidden": "true", children: _jsx(Icon, { name: "lu:chevron-right", size: 16 }) }));
|
|
149
164
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { EntityRef } from '@hashsome/core';
|
|
1
2
|
import type { EntityHandle } from '../entity-handle.ts';
|
|
3
|
+
import type { MediaPlayerEntity, QueueResult } from '@hashsome/core';
|
|
2
4
|
type Handle = EntityHandle<'mediaPlayer'>;
|
|
3
5
|
/** The volume of a player as a 0..100 level plus the drag in progress: what to draw, and what a
|
|
4
6
|
* drag, a commit and the mute button each ask the player to do. 0 is mute and anything above it
|
|
@@ -11,10 +13,26 @@ export declare function useVolumeControl(handle: Handle): {
|
|
|
11
13
|
commit: (next: number) => void;
|
|
12
14
|
toggleMute: () => void;
|
|
13
15
|
};
|
|
14
|
-
/** A press that changes nothing visible until the player
|
|
15
|
-
* the button
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/** A press that changes nothing visible until the player takes it up (play, pause, a skip, shuffle) shows it is
|
|
17
|
+
* waiting: the button is pending (a ring turns around it and it cannot be pressed again) until the player
|
|
18
|
+
* has changed, or turns red when the request fails. The pending part is shared per player, so every control
|
|
19
|
+
* of it agrees. */
|
|
20
|
+
export declare function usePressFeedback(ref: EntityRef | undefined): {
|
|
21
|
+
feedbackFor: (key: string) => 'pending' | 'error' | undefined;
|
|
18
22
|
press: (key: string, action: () => Promise<void>) => void;
|
|
19
23
|
};
|
|
24
|
+
/** What the transport buttons can do now, from the queue: Next when something comes after the track playing,
|
|
25
|
+
* Previous when something comes before it (or the track is far enough in to restart), Play when there is
|
|
26
|
+
* something loaded or queued. Without a queue to read (a player with none, or before it answers) nothing
|
|
27
|
+
* is held back: a button is only disabled for what is known. */
|
|
28
|
+
export declare function transportAvailability(queue: QueueResult | undefined, player: MediaPlayerEntity | undefined, position: number | undefined): {
|
|
29
|
+
next: boolean;
|
|
30
|
+
previous: boolean;
|
|
31
|
+
play: boolean;
|
|
32
|
+
};
|
|
33
|
+
export declare function useTransportAvailability(player: MediaPlayerEntity | undefined, position: number | undefined): {
|
|
34
|
+
next: boolean;
|
|
35
|
+
previous: boolean;
|
|
36
|
+
play: boolean;
|
|
37
|
+
};
|
|
20
38
|
export {};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useMediaQueue } from '../use-media-queue.js';
|
|
3
|
+
import { trackMedia, useMediaPending } from './media-pending.js';
|
|
2
4
|
/** Unmuting while the level is 0 would restore silence, so it jumps to this instead. */
|
|
3
5
|
const UNMUTE_FROM_ZERO = 0.15;
|
|
4
6
|
/** The volume of a player as a 0..100 level plus the drag in progress: what to draw, and what a
|
|
@@ -32,22 +34,55 @@ export function useVolumeControl(handle) {
|
|
|
32
34
|
},
|
|
33
35
|
};
|
|
34
36
|
}
|
|
35
|
-
/** A press that changes nothing visible until the player
|
|
36
|
-
* the button
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
/** A press that changes nothing visible until the player takes it up (play, pause, a skip, shuffle) shows it is
|
|
38
|
+
* waiting: the button is pending (a ring turns around it and it cannot be pressed again) until the player
|
|
39
|
+
* has changed, or turns red when the request fails. The pending part is shared per player, so every control
|
|
40
|
+
* of it agrees. */
|
|
41
|
+
export function usePressFeedback(ref) {
|
|
42
|
+
const waiting = useMediaPending(ref);
|
|
43
|
+
const [failed, setFailed] = useState(null);
|
|
39
44
|
const timer = useRef(undefined);
|
|
40
45
|
useEffect(() => () => clearTimeout(timer.current), []);
|
|
41
46
|
return {
|
|
42
|
-
feedbackFor: (key) => (
|
|
47
|
+
feedbackFor: (key) => waiting.some((entry) => entry.kind === key)
|
|
48
|
+
? 'pending'
|
|
49
|
+
: failed === key
|
|
50
|
+
? 'error'
|
|
51
|
+
: undefined,
|
|
43
52
|
press: (key, action) => {
|
|
44
53
|
clearTimeout(timer.current);
|
|
45
|
-
|
|
46
|
-
action()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
setFailed(null);
|
|
55
|
+
const request = action();
|
|
56
|
+
if (ref) {
|
|
57
|
+
trackMedia(ref, { kind: key }, request);
|
|
58
|
+
}
|
|
59
|
+
request.catch(() => {
|
|
60
|
+
setFailed(key);
|
|
61
|
+
timer.current = setTimeout(() => setFailed(null), 700);
|
|
50
62
|
});
|
|
51
63
|
},
|
|
52
64
|
};
|
|
53
65
|
}
|
|
66
|
+
/** Past this far into a track, Previous restarts it, so it has somewhere to go even on the first one. */
|
|
67
|
+
const RESTART_AFTER_S = 3;
|
|
68
|
+
/** What the transport buttons can do now, from the queue: Next when something comes after the track playing,
|
|
69
|
+
* Previous when something comes before it (or the track is far enough in to restart), Play when there is
|
|
70
|
+
* something loaded or queued. Without a queue to read (a player with none, or before it answers) nothing
|
|
71
|
+
* is held back: a button is only disabled for what is known. */
|
|
72
|
+
export function transportAvailability(queue, player, position) {
|
|
73
|
+
const current = queue ? queue.items.findIndex((item) => item.current === true) : -1;
|
|
74
|
+
const known = queue !== undefined && current >= 0;
|
|
75
|
+
const index = known ? queue.offset + current : 0;
|
|
76
|
+
return {
|
|
77
|
+
next: !known || index + 1 < queue.total,
|
|
78
|
+
previous: !known || index > 0 || (position ?? 0) >= RESTART_AFTER_S,
|
|
79
|
+
play: queue === undefined ||
|
|
80
|
+
queue.total > 0 ||
|
|
81
|
+
player?.media?.title !== undefined ||
|
|
82
|
+
player?.playback === 'playing',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function useTransportAvailability(player, position) {
|
|
86
|
+
const { queue } = useMediaQueue(player?.ref);
|
|
87
|
+
return transportAvailability(queue, player, position);
|
|
88
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EntityRef } from '@hashsome/core';
|
|
2
|
+
/** What a player has been asked to start and is not ready with yet, as the line a player shows in the place
|
|
3
|
+
* of the artist: a spinner and "Loading <title>…". Nothing, when it has been asked nothing. Starting a
|
|
4
|
+
* playlist can take many seconds, during which the player would otherwise look as if nothing was happening. */
|
|
5
|
+
export declare function useLoadingLabel(ref: EntityRef | undefined): string | undefined;
|
|
6
|
+
export declare function LoadingLine({ label, variant }: {
|
|
7
|
+
label: string;
|
|
8
|
+
variant: 'body' | 'secondary';
|
|
9
|
+
}): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
|
|
2
|
+
/** @jsxImportSource @emotion/react */
|
|
3
|
+
import { Flex, Typography } from 'e-prim';
|
|
4
|
+
import { Spinner } from '../layout/spinner.js';
|
|
5
|
+
import { useMediaPending } from './media-pending.js';
|
|
6
|
+
/** What a player has been asked to start and is not ready with yet, as the line a player shows in the place
|
|
7
|
+
* of the artist: a spinner and "Loading <title>…". Nothing, when it has been asked nothing. Starting a
|
|
8
|
+
* playlist can take many seconds, during which the player would otherwise look as if nothing was happening. */
|
|
9
|
+
export function useLoadingLabel(ref) {
|
|
10
|
+
return useMediaPending(ref).find((entry) => entry.kind === 'playItem')?.label;
|
|
11
|
+
}
|
|
12
|
+
export function LoadingLine({ label, variant }) {
|
|
13
|
+
return (_jsxs(Flex, { align: "center", gap: 1.5, minWidth: 0, color: "accent", css: { minWidth: 0 }, children: [_jsx(Spinner, { size: variant === 'body' ? 14 : 12, label: "Loading" }), _jsxs(Typography, { as: "span", variant: variant, noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: ["Loading ", label, "\u2026"] })] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EntityRef } from '@hashsome/core';
|
|
2
|
+
/** Something asked of a player that has not taken effect yet. Starting an album or a playlist can take
|
|
3
|
+
* the player many seconds after it has acknowledged the request, a skip a few, and nothing on screen
|
|
4
|
+
* changes until it does: so the button, the item and the player say it is waiting. */
|
|
5
|
+
export interface MediaPending {
|
|
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`. */
|
|
8
|
+
kind: string;
|
|
9
|
+
/** For the library kinds: the item it was asked for. */
|
|
10
|
+
item?: string;
|
|
11
|
+
/** What to call it while it loads: the item's title. */
|
|
12
|
+
label?: string;
|
|
13
|
+
}
|
|
14
|
+
/** The most a request is shown as pending: a backend that never answers must not leave a ring for good. */
|
|
15
|
+
export declare const PENDING_LIMIT_MS = 60000;
|
|
16
|
+
/** Shows `entry` as pending for `ref` from now until the player has taken it up, whichever way: until it
|
|
17
|
+
* refuses it, or changes after acknowledging it, or a while after (`useMediaPending` reports the change). */
|
|
18
|
+
export declare function trackMedia(ref: string, entry: MediaPending, request: Promise<unknown>): void;
|
|
19
|
+
/** Forgets everything pending: for tests, which share this module between them. */
|
|
20
|
+
export declare function resetMediaPending(): void;
|
|
21
|
+
/** What is being asked of a player and has not taken effect yet: for a ring on the button, a spinner on
|
|
22
|
+
* the item, and a "loading" line on the player. Also watches the player for the change that ends the
|
|
23
|
+
* wait: a different track, play or pause, shuffle. */
|
|
24
|
+
export declare function useMediaPending(ref: EntityRef | undefined): MediaPending[];
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
2
|
+
import { useEntity } from '../hooks.js';
|
|
3
|
+
/** Asked and not in effect yet, for each player by ref. A new array each time it changes, which is
|
|
4
|
+
* what `useSyncExternalStore` compares. */
|
|
5
|
+
const pending = new Map();
|
|
6
|
+
const view = new Map();
|
|
7
|
+
const listeners = new Set();
|
|
8
|
+
const NONE = [];
|
|
9
|
+
/** The most a request is shown as pending: a backend that never answers must not leave a ring for good. */
|
|
10
|
+
export const PENDING_LIMIT_MS = 60_000;
|
|
11
|
+
const lastSeen = new Map();
|
|
12
|
+
/** Asks that change the track are done when another track plays, and not when it is only announced. */
|
|
13
|
+
const changesTrack = (kind) => kind === 'next' || kind === 'previous' || kind === 'playItem';
|
|
14
|
+
/** How long after the player acknowledges a request to wait for it to take effect, when it never
|
|
15
|
+
* visibly does (a skip back on a track already at its start). A skip or a start from the library
|
|
16
|
+
* takes the player several seconds. */
|
|
17
|
+
const settleFor = (kind) => kind === 'playItem' ? 15_000 : kind === 'next' ? 10_000 : kind === 'previous' ? 4_000 : 2_000;
|
|
18
|
+
/** A position past this many seconds means the track plays, and is not only announced. */
|
|
19
|
+
const STARTED_AFTER_S = 0.05;
|
|
20
|
+
/** At least this long is shown for a request, so a quick answer still shows a turn. */
|
|
21
|
+
const MIN_SHOWN_MS = 600;
|
|
22
|
+
const publish = (ref) => {
|
|
23
|
+
const list = pending.get(ref) ?? [];
|
|
24
|
+
if (list.length > 0) {
|
|
25
|
+
view.set(ref, list.map((waiting) => waiting.entry));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
view.delete(ref);
|
|
29
|
+
}
|
|
30
|
+
for (const listener of listeners) {
|
|
31
|
+
listener();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/** Shows `entry` as pending for `ref` from now until the player has taken it up, whichever way: until it
|
|
35
|
+
* refuses it, or changes after acknowledging it, or a while after (`useMediaPending` reports the change). */
|
|
36
|
+
export function trackMedia(ref, entry, request) {
|
|
37
|
+
const waiting = { entry, answered: false, baseTitle: lastSeen.get(ref)?.title };
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
pending.set(ref, [...(pending.get(ref) ?? []), waiting]);
|
|
40
|
+
publish(ref);
|
|
41
|
+
const timers = new Set();
|
|
42
|
+
const finish = () => {
|
|
43
|
+
for (const timer of timers) {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
const rest = (pending.get(ref) ?? []).filter((other) => other !== waiting);
|
|
47
|
+
if (rest.length > 0) {
|
|
48
|
+
pending.set(ref, rest);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
pending.delete(ref);
|
|
52
|
+
}
|
|
53
|
+
publish(ref);
|
|
54
|
+
};
|
|
55
|
+
timers.add(setTimeout(finish, PENDING_LIMIT_MS));
|
|
56
|
+
request.then(() => {
|
|
57
|
+
const shown = Date.now() - started;
|
|
58
|
+
const settle = settleFor(entry.kind);
|
|
59
|
+
waiting.answered = true;
|
|
60
|
+
const taken = () => {
|
|
61
|
+
// The player changed: done, once it has been shown for long enough.
|
|
62
|
+
timers.add(setTimeout(finish, Math.max(0, MIN_SHOWN_MS - (Date.now() - started))));
|
|
63
|
+
};
|
|
64
|
+
waiting.release = taken;
|
|
65
|
+
timers.add(setTimeout(finish, Math.max(settle, MIN_SHOWN_MS - shown)));
|
|
66
|
+
if (waiting.changed) {
|
|
67
|
+
taken();
|
|
68
|
+
}
|
|
69
|
+
}, finish);
|
|
70
|
+
}
|
|
71
|
+
/** The player changed: what it has acknowledged is now in effect, once it is as far as each ask needs. */
|
|
72
|
+
function observe(ref, seen) {
|
|
73
|
+
lastSeen.set(ref, seen);
|
|
74
|
+
for (const waiting of pending.get(ref) ?? []) {
|
|
75
|
+
const done = !changesTrack(waiting.entry.kind) || (seen.title !== waiting.baseTitle && seen.started);
|
|
76
|
+
if (!done) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (waiting.answered) {
|
|
80
|
+
waiting.release?.();
|
|
81
|
+
waiting.release = undefined;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
waiting.changed = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Forgets everything pending: for tests, which share this module between them. */
|
|
89
|
+
export function resetMediaPending() {
|
|
90
|
+
pending.clear();
|
|
91
|
+
view.clear();
|
|
92
|
+
lastSeen.clear();
|
|
93
|
+
}
|
|
94
|
+
const subscribe = (listener) => {
|
|
95
|
+
listeners.add(listener);
|
|
96
|
+
return () => {
|
|
97
|
+
listeners.delete(listener);
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
/** What is being asked of a player and has not taken effect yet: for a ring on the button, a spinner on
|
|
101
|
+
* the item, and a "loading" line on the player. Also watches the player for the change that ends the
|
|
102
|
+
* wait: a different track, play or pause, shuffle. */
|
|
103
|
+
export function useMediaPending(ref) {
|
|
104
|
+
const player = useEntity(ref);
|
|
105
|
+
const media = player?.kind === 'mediaPlayer' ? player : undefined;
|
|
106
|
+
const present = media !== undefined;
|
|
107
|
+
const title = media?.media?.title;
|
|
108
|
+
const started = media?.playback === 'playing' && (media.position ?? 0) >= STARTED_AFTER_S;
|
|
109
|
+
// Anything else the player does (play or pause, shuffle, another album) ends what was asked of it too.
|
|
110
|
+
const playback = media?.playback;
|
|
111
|
+
const album = media?.media?.album;
|
|
112
|
+
const shuffle = media?.shuffle;
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (ref && present) {
|
|
115
|
+
observe(ref, { title, started });
|
|
116
|
+
}
|
|
117
|
+
// `playback`, `album` and `shuffle` are not read inside: a change to any of them is the reason to look again.
|
|
118
|
+
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
|
119
|
+
}, [ref, present, title, started, playback, album, shuffle]);
|
|
120
|
+
return useSyncExternalStore(subscribe, () => (ref ? (view.get(ref) ?? NONE) : NONE), () => NONE);
|
|
121
|
+
}
|
|
@@ -11,13 +11,15 @@ import { HoldProgress } from '../layout/hold-progress.js';
|
|
|
11
11
|
import { useHold } from '../layout/use-hold.js';
|
|
12
12
|
import { IconButton } from '../layout/tile.js';
|
|
13
13
|
import { statusLabels } from '../status.js';
|
|
14
|
-
import { usePressFeedback, useVolumeControl } from './media-controls.js';
|
|
14
|
+
import { usePressFeedback, useTransportAvailability, useVolumeControl } from './media-controls.js';
|
|
15
15
|
import { Cover } from '../layout/cover.js';
|
|
16
|
+
import { Spinner } from '../layout/spinner.js';
|
|
16
17
|
import { PlainButton } from '../layout/plain-button.js';
|
|
17
18
|
import { MediaBrowser } from './media-browser.js';
|
|
19
|
+
import { LoadingLine, useLoadingLabel } from './media-loading-line.js';
|
|
18
20
|
import { MediaPlayerFull } from './media-player-full.js';
|
|
19
21
|
import { MediaQueue } from './media-queue.js';
|
|
20
|
-
import { formatDuration, useMediaPosition } from './media-progress.js';
|
|
22
|
+
import { formatDuration, useMediaPosition, useSteadyPlaying } from './media-progress.js';
|
|
21
23
|
import { useSeekHold } from './media-seek.js';
|
|
22
24
|
import { SeekLine } from './seek-line.js';
|
|
23
25
|
/** Narrower than this, a bar with `rows="auto"` is two rows. */
|
|
@@ -137,14 +139,16 @@ export function MediaPlayerBar({ entity, name, browse, drawer = true, onOpen, ov
|
|
|
137
139
|
const player = handle.entity;
|
|
138
140
|
const status = handle.status;
|
|
139
141
|
const [volumeOpen, setVolumeOpen] = useState(false);
|
|
142
|
+
const loading = useLoadingLabel(player?.ref);
|
|
140
143
|
const ready = status === 'ready';
|
|
141
|
-
const playing = player
|
|
144
|
+
const playing = useSteadyPlaying(player);
|
|
142
145
|
const muted = player?.muted === true;
|
|
143
146
|
const caps = player?.capabilities;
|
|
144
|
-
const { feedbackFor, press } = usePressFeedback();
|
|
147
|
+
const { feedbackFor, press } = usePressFeedback(player?.ref);
|
|
145
148
|
const volume = useVolumeControl(handle);
|
|
146
149
|
const artwork = (_jsx(Flex, { as: "span", center: true, width: 30, height: 30, radius: "full", overflow: "hidden", background: "surfaceRaised", color: "textMuted", children: player?.media?.artworkUrl ? (_jsx(Cover, { src: player.media.artworkUrl })) : (_jsx(Icon, { name: "lu:music", size: 14 })) }));
|
|
147
150
|
const position = useMediaPosition(player);
|
|
151
|
+
const can = useTransportAvailability(player, position);
|
|
148
152
|
const seek = useSeekHold(ready ? position : undefined, caps?.seek ? (next) => void handle.command('seek', { position: next }) : undefined);
|
|
149
153
|
const duration = ready ? player?.duration : undefined;
|
|
150
154
|
const timed = seek.shown !== undefined && duration !== undefined && duration > 0;
|
|
@@ -164,10 +168,10 @@ export function MediaPlayerBar({ entity, name, browse, drawer = true, onOpen, ov
|
|
|
164
168
|
const volumeSlider = (_jsxs(Flex, { as: motion.div, align: "center", gap: 2, grow: 1, minWidth: 0, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.15 }, "data-part": "volume", children: [_jsx(PlainButton, { as: motion.button, initial: { scale: 0, opacity: 0 }, animate: { scale: 1, opacity: 1 }, transition: VOLUME_SPRING, "aria-label": muted ? 'Unmute' : 'Mute', "aria-pressed": muted, title: muted ? 'Unmute' : 'Mute', onClick: volume.toggleMute, center: true, color: muted ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: muted ? 'lu:volume-x' : 'lu:volume-2', size: 16 }) }), _jsx(Flex, { as: motion.div, grow: 1, minWidth: 0, initial: { scaleX: 0.4, opacity: 0 }, animate: { scaleX: 1, opacity: 1 }, transition: { ...VOLUME_SPRING, delay: 0.04 }, css: { transformOrigin: 'left center' }, children: _jsx(ValueBar, { label: "Volume level", value: volume.shown, min: 0, max: 100, keyStep: 5, height: 10, onDrag: volume.drag, onCommit: volume.commit }) }), _jsxs(Typography, { as: motion.span, variant: "secondary", color: "textMuted", minWidth: 32, align: "right", css: { flex: 'none' }, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2, delay: 0.1 }, children: [volume.shown, "%"] })] }));
|
|
165
169
|
const trackInfo = (_jsx(_Fragment, { children: _jsxs(Flex, { "data-part": "track", direction: "column", grow: 1, minWidth: 0, ...(ready ? {} : { color: 'textMuted' }), children: [_jsxs(Flex, { align: "center", gap: 1.5, minWidth: 0, children: [_jsx(Typography, { as: "span", variant: "label", noWrap: true, textOverflow: "ellipsis", children: ready
|
|
166
170
|
? (player?.media?.title ?? 'Nothing playing')
|
|
167
|
-
: statusLabels[status] }), ready && caps?.shuffle ? (_jsx(PlainButton, { "aria-label": "Shuffle", "aria-pressed": player?.shuffle === true, title: "Shuffle", onClick: () =>
|
|
171
|
+
: 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] }) }));
|
|
168
172
|
const bar = (open, openExpanded, openQueue) => (_jsxs(HoldCard, { open: open, status: status, rows: rows, volumeOpen: volumeOpen && ready, children: [_jsx(Ring, { onOpen: open, fraction: progress, children: artwork }), trackInfo, timed ? (_jsxs(Typography, { as: "span", "data-part": "time", variant: "secondary", color: "textMuted", noWrap: true, css: { flex: 'none', fontVariantNumeric: 'tabular-nums' }, children: [formatDuration(seek.shown ?? 0), " / ", formatDuration(duration ?? 0)] })) : null, volumeOpen && ready ? volumeSlider : null, _jsxs(Flex, { "data-part": "tools", align: "center", gap: 3, css: { flex: 'none' }, children: [_jsx(IconButton, { icon: volumeOpen ? 'lu:x' : muted ? 'lu:volume-x' : 'lu:volume-2', label: volumeOpen ? 'Close volume' : 'Volume', glyph: MEDIA_GLYPH, disabled: !ready || (caps?.volume === false && caps.mute === false), onClick: () => setVolumeOpen((current) => !current) }), (overlays
|
|
169
173
|
? openExpanded !== undefined
|
|
170
|
-
: open && (onOpen ? browse !== false : browser !== undefined)) ? (_jsx(IconButton, { icon: "lu:library", label: "Browse media", glyph: MEDIA_GLYPH, onClick: () => (openExpanded ?? open)?.() })) : null, openQueue ? (_jsx(IconButton, { icon: "lu:list-music", label: "Queue", glyph: MEDIA_GLYPH, onClick: openQueue })) : null] }), _jsxs(Flex, { "data-part": "transport", align: "center", gap: 3, css: { flex: 'none' }, children: [_jsx(IconButton, { icon: "lu:skip-back", label: "Previous", glyph: MEDIA_GLYPH, disabled: !ready || caps?.previous === false, feedback: feedbackFor('previous'), onClick: () => press('previous', () => handle.command('previous')) }), _jsx(IconButton, { icon: playing ? 'lu:pause' : 'lu:play', label: playing ? 'Pause' : 'Play', glyph: MEDIA_GLYPH, disabled: !ready, primary: true, onClick: () =>
|
|
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] }));
|
|
171
175
|
// Holding the card, the artwork and the browse button open the same drawer: the player laid out
|
|
172
176
|
// like the vertical one, with the library below it.
|
|
173
177
|
if (!ready) {
|
|
@@ -5,3 +5,7 @@ export declare function formatDuration(seconds: number): string;
|
|
|
5
5
|
* moves it forward from there on its own clock, so the bar advances without a message per second.
|
|
6
6
|
* `undefined` until the player reports a position. */
|
|
7
7
|
export declare function useMediaPosition(player: MediaPlayerEntity | undefined): number | undefined;
|
|
8
|
+
/** Whether to show the player as playing: `playback === 'playing'`, except that a player which flips to
|
|
9
|
+
* idle and back within a moment as a new track starts (as Music Assistant's do) does not make the button
|
|
10
|
+
* change to Play and back. A pause at any other time shows at once. */
|
|
11
|
+
export declare function useSteadyPlaying(player: MediaPlayerEntity | undefined): boolean;
|
|
@@ -28,3 +28,44 @@ export function useMediaPosition(player) {
|
|
|
28
28
|
const position = player.position + elapsed;
|
|
29
29
|
return player.duration === undefined ? position : Math.min(player.duration, position);
|
|
30
30
|
}
|
|
31
|
+
/** How long after a track changes the player may dip out of playing without the button showing it. */
|
|
32
|
+
const NEW_TRACK_MS = 6000;
|
|
33
|
+
/** How long such a dip has to last to be believed. */
|
|
34
|
+
const DIP_MS = 800;
|
|
35
|
+
/** Whether to show the player as playing: `playback === 'playing'`, except that a player which flips to
|
|
36
|
+
* idle and back within a moment as a new track starts (as Music Assistant's do) does not make the button
|
|
37
|
+
* change to Play and back. A pause at any other time shows at once. */
|
|
38
|
+
export function useSteadyPlaying(player) {
|
|
39
|
+
const raw = player?.playback === 'playing';
|
|
40
|
+
const track = player?.media?.title;
|
|
41
|
+
// A track change opens a short window, in which a dip out of playing is held up for a moment.
|
|
42
|
+
const [seen, setSeen] = useState(track);
|
|
43
|
+
const [recent, setRecent] = useState(false);
|
|
44
|
+
if (seen !== track) {
|
|
45
|
+
setSeen(track);
|
|
46
|
+
setRecent(true);
|
|
47
|
+
}
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!recent) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const id = setTimeout(() => setRecent(false), NEW_TRACK_MS);
|
|
53
|
+
return () => clearTimeout(id);
|
|
54
|
+
// `track` is not read inside: another change of track restarts the window.
|
|
55
|
+
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
|
56
|
+
}, [recent, track]);
|
|
57
|
+
const [was, setWas] = useState(raw);
|
|
58
|
+
const [held, setHeld] = useState(false);
|
|
59
|
+
if (raw !== was) {
|
|
60
|
+
setWas(raw);
|
|
61
|
+
setHeld(!raw && recent);
|
|
62
|
+
}
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!held) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const id = setTimeout(() => setHeld(false), DIP_MS);
|
|
68
|
+
return () => clearTimeout(id);
|
|
69
|
+
}, [held]);
|
|
70
|
+
return raw || held;
|
|
71
|
+
}
|
|
@@ -7,10 +7,11 @@ import { ValueBar } from '../layout/drawer-controls.js';
|
|
|
7
7
|
import { PlainButton } from '../layout/plain-button.js';
|
|
8
8
|
import { IconButton } from '../layout/tile.js';
|
|
9
9
|
import { statusLabels } from '../status.js';
|
|
10
|
-
import { usePressFeedback, useVolumeControl } from './media-controls.js';
|
|
11
|
-
import { formatDuration, useMediaPosition } from './media-progress.js';
|
|
10
|
+
import { usePressFeedback, useTransportAvailability, useVolumeControl } from './media-controls.js';
|
|
11
|
+
import { formatDuration, useMediaPosition, useSteadyPlaying } from './media-progress.js';
|
|
12
12
|
import { useSeekHold } from './media-seek.js';
|
|
13
13
|
import { ArtworkRing } from './artwork-ring.js';
|
|
14
|
+
import { LoadingLine, useLoadingLabel } from './media-loading-line.js';
|
|
14
15
|
/** The upright "now playing" panel the media column and page share: the artwork on a record, what is playing, how
|
|
15
16
|
* far along it is, the transport buttons and a volume bar that is always there. Not exported from
|
|
16
17
|
* the package; `MediaPlayerColumn` and `MediaPlayerFull` are its public forms. */
|
|
@@ -19,12 +20,14 @@ export function NowPlaying({ handle, fallback, name, extra, leading, onOpenArtwo
|
|
|
19
20
|
const { iconCircle, space } = useTheme().density;
|
|
20
21
|
const transportWidth = 5 * iconCircle + 4 * space;
|
|
21
22
|
const { status } = handle;
|
|
23
|
+
const loading = useLoadingLabel(player?.ref);
|
|
22
24
|
const ready = status === 'ready';
|
|
23
25
|
const caps = player?.capabilities;
|
|
24
|
-
const playing = player
|
|
25
|
-
const { feedbackFor, press } = usePressFeedback();
|
|
26
|
+
const playing = useSteadyPlaying(player);
|
|
27
|
+
const { feedbackFor, press } = usePressFeedback(player?.ref);
|
|
26
28
|
const volume = useVolumeControl(handle);
|
|
27
29
|
const position = useMediaPosition(player);
|
|
30
|
+
const can = useTransportAvailability(player, position);
|
|
28
31
|
const duration = ready ? player?.duration : undefined;
|
|
29
32
|
// The ring and the time under the title share one position, so dragging the ring moves both.
|
|
30
33
|
const seek = useSeekHold(ready ? position : undefined, caps?.seek ? (next) => void handle.command('seek', { position: next }) : undefined);
|
|
@@ -32,8 +35,8 @@ export function NowPlaying({ handle, fallback, name, extra, leading, onOpenArtwo
|
|
|
32
35
|
// No wider than the row of five buttons under it (268px: five circles and the gaps between them).
|
|
33
36
|
maxSize: transportWidth }), _jsxs(Flex, { direction: "column", align: "center", gap: 0.5, color: ready ? 'text' : 'textMuted', children: [_jsxs(Flex, { align: "center", justify: "center", gap: 2, maxWidth: "100%", children: [_jsx(Typography, { as: "h2", variant: "title", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", m: 0, minWidth: 0, children: ready
|
|
34
37
|
? (player?.media?.title ?? 'Nothing playing')
|
|
35
|
-
: statusLabels[status] }), caps?.shuffle && shufflePlacement === 'title' ? (_jsx(IconButton, { icon: "lu:shuffle", label: "Shuffle", size: 14, active: player?.shuffle === true, pressed: player?.shuffle === true, disabled: !ready, onClick: () =>
|
|
38
|
+
: statusLabels[status] }), caps?.shuffle && shufflePlacement === 'title' ? (_jsx(IconButton, { icon: "lu:shuffle", label: "Shuffle", size: 14, active: player?.shuffle === true, pressed: player?.shuffle === true, disabled: !ready, feedback: feedbackFor('setShuffle'), onClick: () => press('setShuffle', () => handle.command('setShuffle', { shuffle: player?.shuffle !== true })) })) : null] }), loading ? (_jsx(LoadingLine, { label: loading, variant: "body" })) : ready && player?.media?.artist ? (_jsx(Typography, { as: "span", variant: "body", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: [player.media.artist, player.media.album].filter(Boolean).join(' · ') })) : (_jsx(Typography, { as: "span", variant: "body", color: "textMuted", noWrap: true, textOverflow: "ellipsis", overflow: "hidden", children: name ?? player?.name ?? fallback }))] }), seek.shown !== undefined && duration ? (_jsxs(Typography, { as: "span", variant: "secondary", color: "textMuted", align: "center", css: { fontVariantNumeric: 'tabular-nums' }, children: [formatDuration(seek.shown), " / ", formatDuration(duration)] })) : null, _jsxs(Grid, { gap: 3, css: ({ density }) => ({
|
|
36
39
|
gridTemplateColumns: `repeat(5, ${density.iconCircle}px)`,
|
|
37
40
|
justifyContent: 'center',
|
|
38
|
-
}), children: [caps?.shuffle && shufflePlacement === 'transport' ? (_jsx(IconButton, { icon: "lu:shuffle", label: "Shuffle", glyph: 18, active: player?.shuffle === true, pressed: player?.shuffle === true, disabled: !ready, onClick: () =>
|
|
41
|
+
}), children: [caps?.shuffle && shufflePlacement === 'transport' ? (_jsx(IconButton, { icon: "lu:shuffle", label: "Shuffle", glyph: 18, active: player?.shuffle === true, pressed: player?.shuffle === true, disabled: !ready, feedback: feedbackFor('setShuffle'), onClick: () => press('setShuffle', () => handle.command('setShuffle', { shuffle: player?.shuffle !== true })) })) : shufflePlacement === 'title' ? ((leading ?? _jsx("span", {}))) : (_jsx("span", {})), _jsx(IconButton, { icon: "lu:skip-back", label: "Previous", glyph: 18, 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: 18, disabled: !ready || !can.play, primary: true, feedback: feedbackFor('togglePlay'), onClick: () => press('togglePlay', () => handle.command('togglePlay')) }), _jsx(IconButton, { icon: "lu:skip-forward", label: "Next", glyph: 18, disabled: !ready || caps?.next === false || !can.next, feedback: feedbackFor('next'), onClick: () => press('next', () => handle.command('next')) }), extra ?? _jsx("span", {})] }), caps?.volume !== false || caps.mute ? (_jsxs(Flex, { align: "center", gap: 2, children: [_jsx(PlainButton, { "aria-label": volume.muted ? 'Unmute' : 'Mute', "aria-pressed": volume.muted, title: volume.muted ? 'Unmute' : 'Mute', disabled: !ready || caps?.mute === false, onClick: volume.toggleMute, center: true, color: volume.muted ? 'accent' : 'textMuted', css: { flex: 'none' }, children: _jsx(Icon, { name: volume.muted ? 'lu:volume-x' : 'lu:volume-2', size: 16 }) }), _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: "span", variant: "secondary", color: "textMuted", minWidth: 32, align: "right", css: { flex: 'none', fontVariantNumeric: 'tabular-nums' }, children: [volume.shown, "%"] })] })) : null] }));
|
|
39
42
|
}
|