@joewinke/jatui 0.59.0 → 0.61.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/CLAUDE.md +4 -1
- package/dist/actions/tooltip.d.ts +7 -0
- package/dist/actions/tooltip.js +28 -0
- package/dist/ambience/README.md +9 -2
- package/dist/ambience/engine.d.ts +2 -0
- package/dist/ambience/engine.js +101 -4
- package/dist/ambience/scapes/playlist.d.ts +15 -0
- package/dist/ambience/scapes/playlist.js +81 -4
- package/dist/ambience/shared.d.ts +69 -2
- package/dist/ambience/shared.js +110 -8
- package/dist/ambience/types.d.ts +19 -0
- package/dist/components/AmbienceToggle.svelte +260 -11
- package/dist/components/AmbienceToggle.svelte.d.ts +17 -3
- package/dist/components/stats/StatusBanner.svelte +119 -0
- package/dist/components/stats/StatusBanner.svelte.d.ts +13 -0
- package/dist/components/stats/StatusCard.svelte +176 -0
- package/dist/components/stats/StatusCard.svelte.d.ts +18 -0
- package/dist/components/stats/UptimeBar.svelte +254 -0
- package/dist/components/stats/UptimeBar.svelte.d.ts +20 -0
- package/dist/components/stats/uptime.d.ts +105 -0
- package/dist/components/stats/uptime.js +213 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
package/dist/ambience/shared.js
CHANGED
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* Transport (always BroadcastChannel 'jatui:ambience'):
|
|
16
16
|
* - `state` / `heartbeat` — the leader publishes the live shared state
|
|
17
17
|
* (playing scape, volume, muted, track attribution, its tab id).
|
|
18
|
-
* - `cmd` — a remote tells the leader to stop /
|
|
19
|
-
* play (play only when the fallback
|
|
18
|
+
* - `cmd` — a remote tells the leader to stop / pause / resume / skip / go
|
|
19
|
+
* back / setVolume / toggleMute / play (play only when the fallback
|
|
20
|
+
* election defers to a live leader).
|
|
20
21
|
* - `want` / `claim` — the heartbeat election.
|
|
21
22
|
* - `gone` — the leader's pagehide: remotes immediately mark the leader
|
|
22
23
|
* gone (playback stops — never a surprise autoplay in a background tab).
|
|
@@ -115,6 +116,10 @@ export function createSharedAmbience(options = {}) {
|
|
|
115
116
|
/** The leader's now-playing line, published via setNowPlaying. */
|
|
116
117
|
let nowPlaying = null;
|
|
117
118
|
let nowPlayingIndex = null;
|
|
119
|
+
/** Structured now-playing parts published alongside the line (jatui-153tz). */
|
|
120
|
+
let nowPlayingTitle = null;
|
|
121
|
+
let nowPlayingPosition = null;
|
|
122
|
+
let nowPlayingDuration = null;
|
|
118
123
|
const ambienceSubs = new Set();
|
|
119
124
|
const sharedSubs = new Set();
|
|
120
125
|
function sharedState() {
|
|
@@ -122,18 +127,27 @@ export function createSharedAmbience(options = {}) {
|
|
|
122
127
|
return {
|
|
123
128
|
currentId: role === 'leader' ? st.currentId : (remoteS?.currentId ?? st.currentId),
|
|
124
129
|
playing: role === 'leader' ? st.playing : (remoteS?.playing ?? false),
|
|
130
|
+
paused: role === 'leader' ? st.paused : (remoteS?.paused ?? false),
|
|
125
131
|
volume: userVolume,
|
|
126
132
|
muted,
|
|
127
133
|
role,
|
|
128
134
|
leaderTabId: role === 'leader' ? tabId : (remoteS?.leaderTabId ?? null),
|
|
129
135
|
leaderGone: role === 'leader' ? false : leaderGone,
|
|
130
136
|
nowPlaying: role === 'leader' ? nowPlaying : (remoteS?.nowPlaying ?? null),
|
|
131
|
-
trackIndex: role === 'leader' ? nowPlayingIndex : (remoteS?.trackIndex ?? null)
|
|
137
|
+
trackIndex: role === 'leader' ? nowPlayingIndex : (remoteS?.trackIndex ?? null),
|
|
138
|
+
trackTitle: role === 'leader' ? nowPlayingTitle : (remoteS?.trackTitle ?? null),
|
|
139
|
+
trackPosition: role === 'leader' ? nowPlayingPosition : (remoteS?.trackPosition ?? null),
|
|
140
|
+
trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null)
|
|
132
141
|
};
|
|
133
142
|
}
|
|
134
143
|
function ambienceState() {
|
|
135
144
|
const s = sharedState();
|
|
136
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
currentId: s.currentId,
|
|
147
|
+
playing: s.playing && !s.leaderGone,
|
|
148
|
+
paused: s.paused,
|
|
149
|
+
volume: s.volume
|
|
150
|
+
};
|
|
137
151
|
}
|
|
138
152
|
function emitShared() {
|
|
139
153
|
const s = sharedState();
|
|
@@ -189,6 +203,9 @@ export function createSharedAmbience(options = {}) {
|
|
|
189
203
|
role = 'remote';
|
|
190
204
|
nowPlaying = null;
|
|
191
205
|
nowPlayingIndex = null;
|
|
206
|
+
nowPlayingTitle = null;
|
|
207
|
+
nowPlayingPosition = null;
|
|
208
|
+
nowPlayingDuration = null;
|
|
192
209
|
remoteS = null;
|
|
193
210
|
if (heartbeatTimer) {
|
|
194
211
|
clearInterval(heartbeatTimer);
|
|
@@ -387,7 +404,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
387
404
|
// The leader tab is closing: mark it gone at once — no staleness
|
|
388
405
|
// wait, and never a surprise autoplay in a background tab.
|
|
389
406
|
if (remoteS && (remoteS.leaderTabId === msg.from || lastBeatTab === msg.from)) {
|
|
390
|
-
remoteS = { ...remoteS, playing: false, nowPlaying: null };
|
|
407
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
391
408
|
leaderGone = true;
|
|
392
409
|
emitShared();
|
|
393
410
|
}
|
|
@@ -400,6 +417,34 @@ export function createSharedAmbience(options = {}) {
|
|
|
400
417
|
case 'stop':
|
|
401
418
|
local.stop();
|
|
402
419
|
break;
|
|
420
|
+
case 'pause':
|
|
421
|
+
local.pause();
|
|
422
|
+
break;
|
|
423
|
+
case 'resume':
|
|
424
|
+
remoteCommand = true;
|
|
425
|
+
local
|
|
426
|
+
.resume()
|
|
427
|
+
.catch(() => {
|
|
428
|
+
if (role === 'leader')
|
|
429
|
+
publish();
|
|
430
|
+
})
|
|
431
|
+
.finally(() => {
|
|
432
|
+
remoteCommand = false;
|
|
433
|
+
});
|
|
434
|
+
break;
|
|
435
|
+
case 'skip':
|
|
436
|
+
case 'prev': {
|
|
437
|
+
// The now-playing row's transport (jatui-153tz): routed to the
|
|
438
|
+
// leader's own playlist scape instance.
|
|
439
|
+
if (!msg.id)
|
|
440
|
+
return;
|
|
441
|
+
const p = playlistFor(msg.id);
|
|
442
|
+
if (msg.cmd === 'skip')
|
|
443
|
+
p?.skip();
|
|
444
|
+
else
|
|
445
|
+
p?.prev();
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
403
448
|
case 'setVolume':
|
|
404
449
|
applyVolume(msg.arg ?? userVolume);
|
|
405
450
|
break;
|
|
@@ -442,7 +487,7 @@ export function createSharedAmbience(options = {}) {
|
|
|
442
487
|
if (remoteS?.playing &&
|
|
443
488
|
lastBeatAt > 0 &&
|
|
444
489
|
Date.now() - lastBeatAt > staleMs) {
|
|
445
|
-
remoteS = { ...remoteS, playing: false, nowPlaying: null };
|
|
490
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
446
491
|
leaderGone = true;
|
|
447
492
|
emitShared();
|
|
448
493
|
}
|
|
@@ -526,9 +571,53 @@ export function createSharedAmbience(options = {}) {
|
|
|
526
571
|
post({ t: 'cmd', cmd: 'stop', from: tabId });
|
|
527
572
|
// Optimistic: the leader echoes the authoritative state right after.
|
|
528
573
|
if (remoteS)
|
|
529
|
-
remoteS = { ...remoteS, playing: false, nowPlaying: null };
|
|
574
|
+
remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
|
|
575
|
+
emitShared();
|
|
576
|
+
}
|
|
577
|
+
/** Freeze in place (the scape is not torn down; a playlist keeps its track
|
|
578
|
+
* position). Leader-side it pauses the local engine; a remote asks the
|
|
579
|
+
* leader to — the echo of the authoritative state follows. */
|
|
580
|
+
function pause() {
|
|
581
|
+
if (role === 'leader') {
|
|
582
|
+
local.pause(); // publish happens via the engine subscription
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
post({ t: 'cmd', cmd: 'pause', from: tabId });
|
|
586
|
+
if (remoteS)
|
|
587
|
+
remoteS = { ...remoteS, playing: false, paused: true };
|
|
588
|
+
emitShared();
|
|
589
|
+
}
|
|
590
|
+
/** Continue a paused scape. No-op when nothing is paused. */
|
|
591
|
+
async function resume() {
|
|
592
|
+
if (role === 'leader') {
|
|
593
|
+
await local.resume().catch(() => {
|
|
594
|
+
if (role === 'leader')
|
|
595
|
+
publish();
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
post({ t: 'cmd', cmd: 'resume', from: tabId });
|
|
600
|
+
if (remoteS)
|
|
601
|
+
remoteS = { ...remoteS, playing: true, paused: false };
|
|
530
602
|
emitShared();
|
|
531
603
|
}
|
|
604
|
+
/** Transport for the now-playing row (jatui-153tz): skip to the next track
|
|
605
|
+
* (or go back one) on a playlist scape — routed to the leader's live
|
|
606
|
+
* instance when this tab is a remote. */
|
|
607
|
+
function skipTrack(id) {
|
|
608
|
+
if (role === 'leader') {
|
|
609
|
+
playlistFor(id)?.skip();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
post({ t: 'cmd', cmd: 'skip', id, from: tabId });
|
|
613
|
+
}
|
|
614
|
+
function prevTrack(id) {
|
|
615
|
+
if (role === 'leader') {
|
|
616
|
+
playlistFor(id)?.prev();
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
post({ t: 'cmd', cmd: 'prev', id, from: tabId });
|
|
620
|
+
}
|
|
532
621
|
function setVolume(v) {
|
|
533
622
|
applyVolume(v);
|
|
534
623
|
savePersisted(storageKey, {
|
|
@@ -594,6 +683,10 @@ export function createSharedAmbience(options = {}) {
|
|
|
594
683
|
groups: () => local.groups(),
|
|
595
684
|
play,
|
|
596
685
|
stop,
|
|
686
|
+
pause,
|
|
687
|
+
resume,
|
|
688
|
+
skipTrack,
|
|
689
|
+
prevTrack,
|
|
597
690
|
kill: () => {
|
|
598
691
|
// A remote has nothing local to kill — tell the leader instead.
|
|
599
692
|
if (role === 'leader')
|
|
@@ -604,9 +697,12 @@ export function createSharedAmbience(options = {}) {
|
|
|
604
697
|
setVolume,
|
|
605
698
|
setMuted,
|
|
606
699
|
toggleMute,
|
|
607
|
-
setNowPlaying(line, index) {
|
|
700
|
+
setNowPlaying(line, index, position, duration, title) {
|
|
608
701
|
nowPlaying = line;
|
|
609
702
|
nowPlayingIndex = line ? (index ?? null) : null;
|
|
703
|
+
nowPlayingPosition = line ? (position ?? null) : null;
|
|
704
|
+
nowPlayingDuration = line ? (duration ?? null) : null;
|
|
705
|
+
nowPlayingTitle = line ? (title ?? null) : null;
|
|
610
706
|
if (role === 'leader') {
|
|
611
707
|
publish();
|
|
612
708
|
emitShared();
|
|
@@ -640,3 +736,9 @@ export function createSharedAmbience(options = {}) {
|
|
|
640
736
|
export function createAmbience(options = {}) {
|
|
641
737
|
return options.shared ? createSharedAmbience(options) : createLocalEngine(options);
|
|
642
738
|
}
|
|
739
|
+
/** The always-local engine factory — the barrel's `createAmbienceLocal`.
|
|
740
|
+
* The ambience barrel has re-exported this name since the shared player
|
|
741
|
+
* landed (jatui-lm1mf), but shared.ts never defined it: real ESM (the built
|
|
742
|
+
* dist, Node) failed to import the subpath at all (jatui-153tz rebuild). It
|
|
743
|
+
* is the plain engine: `shared` is never honored, whatever the options. */
|
|
744
|
+
export const createAmbienceLocal = createLocalEngine;
|
package/dist/ambience/types.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface ScapeInstance {
|
|
|
27
27
|
stop(at: number, fade: number): void;
|
|
28
28
|
/** Optional live parameter (scape-defined keys). */
|
|
29
29
|
setParam?(key: string, value: number): void;
|
|
30
|
+
/** Optional live pause: freeze in place without tearing down (a playlist
|
|
31
|
+
* pauses its media elements and keeps the track position). Called by the
|
|
32
|
+
* engine's pause() after its fade; scapes without it are just faded. */
|
|
33
|
+
pause?(): void;
|
|
34
|
+
/** Optional resume counterpart of pause() — continue where you froze. */
|
|
35
|
+
resume?(): void;
|
|
30
36
|
}
|
|
31
37
|
/**
|
|
32
38
|
* A menu section. The named members are the library's own groups (radio holds
|
|
@@ -74,6 +80,10 @@ export interface AmbienceState {
|
|
|
74
80
|
/** Currently selected scape id (persisted preselect counts), or null. */
|
|
75
81
|
currentId: string | null;
|
|
76
82
|
playing: boolean;
|
|
83
|
+
/** True while paused via pause(): the scape instance is frozen in place
|
|
84
|
+
* (a playlist keeps its track position), not torn down — resume()
|
|
85
|
+
* continues it from the same moment. Cleared by play/stop/kill. */
|
|
86
|
+
paused: boolean;
|
|
77
87
|
volume: number;
|
|
78
88
|
}
|
|
79
89
|
export interface BreathState {
|
|
@@ -97,6 +107,15 @@ export interface Ambience {
|
|
|
97
107
|
/** Fade out ~0.55 s; the AudioContext closes ~3 s later. A play() inside
|
|
98
108
|
* that window cancels the close and fades back in over ~4 s. */
|
|
99
109
|
stop(): void;
|
|
110
|
+
/** Pause without tearing down: a short fade out, then the scape freezes in
|
|
111
|
+
* place (a playlist keeps its track and position; the context suspends so
|
|
112
|
+
* generative scapes stop evolving and the CPU rests). resume() continues
|
|
113
|
+
* from the same moment; play() restarts fresh. No-op while not playing. */
|
|
114
|
+
pause(): void;
|
|
115
|
+
/** Resume a paused scape — MUST be called from a user gesture (it resumes
|
|
116
|
+
* the suspended AudioContext). No-op unless paused. Rejects like play()
|
|
117
|
+
* on a refused gesture. */
|
|
118
|
+
resume(): Promise<void>;
|
|
100
119
|
/** Immediate: no fades, context closed, timers cleared. */
|
|
101
120
|
kill(): void;
|
|
102
121
|
setVolume(v: number): void;
|
|
@@ -23,6 +23,15 @@
|
|
|
23
23
|
* real `<a target="_blank">` menu items with an external-link glyph:
|
|
24
24
|
* the station's own site or player opens in a new tab. Nothing is
|
|
25
25
|
* relayed — we never proxy a third-party stream.
|
|
26
|
+
* - A playing (or paused) scape shows a now-playing block above the
|
|
27
|
+
* volume row (jatui-153tz): for a playlist — the track title, its
|
|
28
|
+
* composer · license attribution, an elapsed/total progress bar and a
|
|
29
|
+
* prev / play-pause / next transport (prev restarts the track when more
|
|
30
|
+
* than 3 s are in, else goes back one; play-pause freezes the station
|
|
31
|
+
* without closing the menu; next skips). Generative scapes (noise /
|
|
32
|
+
* focus) get the play/pause control only. The transport joins the
|
|
33
|
+
* menu's roving order; a paused row stays visible (its play button is
|
|
34
|
+
* the resume) and a stopped one hides.
|
|
26
35
|
* - A playing playlist shows its current track's attribution (title,
|
|
27
36
|
* composer, license) under the playlist item and its section row.
|
|
28
37
|
* - With a menu or submenu open, the mouse wheel over that surface
|
|
@@ -79,14 +88,38 @@
|
|
|
79
88
|
return `${track.title} — ${trackAttribution(track)}`;
|
|
80
89
|
}
|
|
81
90
|
|
|
91
|
+
/** "3:07" — m:ss for the now-playing row's elapsed/total (jatui-153tz). */
|
|
92
|
+
export function formatTrackTime(seconds: number | null | undefined): string {
|
|
93
|
+
if (
|
|
94
|
+
seconds === null ||
|
|
95
|
+
seconds === undefined ||
|
|
96
|
+
!Number.isFinite(seconds) ||
|
|
97
|
+
seconds < 0
|
|
98
|
+
)
|
|
99
|
+
return '0:00';
|
|
100
|
+
const s = Math.floor(seconds);
|
|
101
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The now-playing progress fill, 0–100, clamped; 0 without a duration. */
|
|
105
|
+
export function progressPercent(position: number, duration: number): number {
|
|
106
|
+
if (!Number.isFinite(position) || !Number.isFinite(duration) || duration <= 0) return 0;
|
|
107
|
+
return Math.min(100, Math.max(0, (position / duration) * 100));
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
/**
|
|
83
111
|
* The live label — "Ambience: Hibiscus, playing" while sounding,
|
|
84
|
-
* "Ambience: Hibiscus,
|
|
85
|
-
* playing, "Ambience: off" before
|
|
112
|
+
* "Ambience: Hibiscus, paused" while paused, "Ambience: Hibiscus, stopped"
|
|
113
|
+
* once a scape has been selected but is not playing, "Ambience: off" before
|
|
114
|
+
* anything was selected.
|
|
86
115
|
*/
|
|
87
|
-
export function ambienceLiveLabel(
|
|
116
|
+
export function ambienceLiveLabel(
|
|
117
|
+
currentLabel: string | null,
|
|
118
|
+
playing: boolean,
|
|
119
|
+
paused = false
|
|
120
|
+
): string {
|
|
88
121
|
if (!currentLabel) return 'Ambience: off';
|
|
89
|
-
return `Ambience: ${currentLabel}, ${playing ? 'playing' : 'stopped'}`;
|
|
122
|
+
return `Ambience: ${currentLabel}, ${playing ? 'playing' : paused ? 'paused' : 'stopped'}`;
|
|
90
123
|
}
|
|
91
124
|
|
|
92
125
|
/** Transient failure label — a rejected engine.play() (gesture probe,
|
|
@@ -201,7 +234,7 @@
|
|
|
201
234
|
import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
|
|
202
235
|
import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
|
|
203
236
|
import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
|
|
204
|
-
import type { SharedAmbience } from '../ambience/shared';
|
|
237
|
+
import type { SharedAmbience, SharedAmbienceState } from '../ambience/shared';
|
|
205
238
|
|
|
206
239
|
let {
|
|
207
240
|
engine,
|
|
@@ -228,6 +261,8 @@
|
|
|
228
261
|
let playing = $state(false);
|
|
229
262
|
let currentId = $state<string | null>(null);
|
|
230
263
|
let volume = $state(0.6);
|
|
264
|
+
/** True while the engine is paused (pause() — frozen in place, resumable). */
|
|
265
|
+
let paused = $state(false);
|
|
231
266
|
let items = $state<AmbienceItem[]>([]);
|
|
232
267
|
let open = $state(false);
|
|
233
268
|
// svelte-ignore state_referenced_locally -- only the seed; resolvePlacement() reads the live prop
|
|
@@ -260,10 +295,20 @@
|
|
|
260
295
|
|
|
261
296
|
let playError = $state(false);
|
|
262
297
|
let track = $state<PlaylistState['track']>(null);
|
|
298
|
+
/** The current playlist scape's live snapshot (jatui-153tz): drives the
|
|
299
|
+
* now-playing row's progress bar. Null on plain/remote tabs without a
|
|
300
|
+
* local playing instance. */
|
|
301
|
+
let playlist = $state<PlaylistState | null>(null);
|
|
263
302
|
/** The shared player's now-playing line (jatui-lm1mf): published by the
|
|
264
303
|
* leader tab, so a remote tab shows the same track attribution without
|
|
265
304
|
* owning the playlist instance. Null on a plain engine. */
|
|
266
305
|
let sharedNow = $state<string | null>(null);
|
|
306
|
+
/** Shared-player extras (jatui-153tz): this tab's role and the leader's
|
|
307
|
+
* published structured now-playing parts. */
|
|
308
|
+
let sharedRole = $state<SharedAmbienceState['role'] | null>(null);
|
|
309
|
+
let sharedTitle = $state<string | null>(null);
|
|
310
|
+
let sharedPosition = $state<number | null>(null);
|
|
311
|
+
let sharedDuration = $state<number | null>(null);
|
|
267
312
|
|
|
268
313
|
// Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
|
|
269
314
|
// the pending partial notch between wheel events so trackpad pixel deltas
|
|
@@ -276,13 +321,45 @@
|
|
|
276
321
|
const currentLabel = $derived(currentItem?.label ?? currentId);
|
|
277
322
|
const groups = $derived(groupScapes(items, engine.groups?.()));
|
|
278
323
|
const openSection = $derived(groups.find((g) => g.group === subGroup) ?? null);
|
|
279
|
-
const trackLine = $derived(
|
|
324
|
+
const trackLine = $derived(
|
|
325
|
+
playing || paused ? (sharedNow ?? nowPlayingLine(track)) : ''
|
|
326
|
+
);
|
|
280
327
|
const liveLabel = $derived(
|
|
281
|
-
playError
|
|
328
|
+
playError
|
|
329
|
+
? PLAY_ERROR_LABEL
|
|
330
|
+
: ambienceLiveLabel(currentLabel, playing, paused)
|
|
282
331
|
);
|
|
283
332
|
const disabled = $derived(items.length === 0);
|
|
284
333
|
const sliderValue = $derived(Math.round(volume * 100));
|
|
285
334
|
|
|
335
|
+
// --- now-playing row (jatui-153tz) ---------------------------------------
|
|
336
|
+
/** A remote tab (shared engine, not the leader): renders the leader's
|
|
337
|
+
* published state and routes transport commands to it. */
|
|
338
|
+
const isRemote = $derived(sharedRole === 'remote');
|
|
339
|
+
const currentIsPlaylist = $derived(isPlaylistScape(currentItem));
|
|
340
|
+
/** The row exists while something is sounding or paused — a paused row
|
|
341
|
+
* must stay (its play button is how you resume); a stopped one hides. */
|
|
342
|
+
const showNowPlaying = $derived((playing || paused) && !!currentItem);
|
|
343
|
+
const npTitle = $derived(
|
|
344
|
+
isRemote
|
|
345
|
+
? (sharedTitle ?? '')
|
|
346
|
+
: (playlist?.track?.title ?? (currentIsPlaylist ? '' : (currentLabel ?? '')))
|
|
347
|
+
);
|
|
348
|
+
const npAttrib = $derived(
|
|
349
|
+
isRemote
|
|
350
|
+
? (sharedNow && sharedTitle && sharedNow.startsWith(`${sharedTitle} — `)
|
|
351
|
+
? sharedNow.slice(sharedTitle.length + 3)
|
|
352
|
+
: (sharedNow ?? ''))
|
|
353
|
+
: (playlist?.track ? trackAttribution(playlist.track) : '')
|
|
354
|
+
);
|
|
355
|
+
const npPosition = $derived(
|
|
356
|
+
currentIsPlaylist ? (isRemote ? (sharedPosition ?? 0) : (playlist?.position ?? 0)) : 0
|
|
357
|
+
);
|
|
358
|
+
const npDuration = $derived(
|
|
359
|
+
currentIsPlaylist ? (isRemote ? (sharedDuration ?? 0) : (playlist?.duration ?? 0)) : 0
|
|
360
|
+
);
|
|
361
|
+
const npPercent = $derived(progressPercent(npPosition, npDuration));
|
|
362
|
+
|
|
286
363
|
const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
|
|
287
364
|
const ICON_SIZE = $derived(compact ? 18 : 20);
|
|
288
365
|
|
|
@@ -295,6 +372,7 @@
|
|
|
295
372
|
$effect(() => {
|
|
296
373
|
return engine.subscribe((s) => {
|
|
297
374
|
playing = s.playing;
|
|
375
|
+
paused = s.paused;
|
|
298
376
|
currentId = s.currentId;
|
|
299
377
|
volume = s.volume;
|
|
300
378
|
items = snapshot();
|
|
@@ -303,32 +381,47 @@
|
|
|
303
381
|
});
|
|
304
382
|
});
|
|
305
383
|
|
|
306
|
-
// The shared player's published now-playing line (leader → remotes)
|
|
384
|
+
// The shared player's published now-playing line (leader → remotes), plus
|
|
385
|
+
// this tab's role and the structured parts the now-playing row renders.
|
|
307
386
|
$effect(() => {
|
|
308
387
|
const e = engine as SharedAmbience;
|
|
309
388
|
if (typeof e.subscribeShared !== 'function') {
|
|
310
389
|
sharedNow = null;
|
|
390
|
+
sharedRole = null;
|
|
391
|
+
sharedTitle = null;
|
|
392
|
+
sharedPosition = null;
|
|
393
|
+
sharedDuration = null;
|
|
311
394
|
return;
|
|
312
395
|
}
|
|
313
396
|
return e.subscribeShared((s) => {
|
|
314
397
|
sharedNow = s.nowPlaying;
|
|
398
|
+
sharedRole = s.role;
|
|
399
|
+
sharedTitle = s.trackTitle;
|
|
400
|
+
sharedPosition = s.trackPosition;
|
|
401
|
+
sharedDuration = s.trackDuration;
|
|
315
402
|
});
|
|
316
403
|
});
|
|
317
404
|
|
|
318
|
-
// The current scape is a playlist → follow its track for the attribution line
|
|
319
|
-
// and (leader side) publish it to the other tabs via
|
|
405
|
+
// The current scape is a playlist → follow its track for the attribution line
|
|
406
|
+
// and the progress bar, and (leader side) publish it to the other tabs via
|
|
407
|
+
// the shared engine.
|
|
320
408
|
$effect(() => {
|
|
321
409
|
const item = currentItem;
|
|
322
410
|
if (!isPlaylistScape(item)) {
|
|
323
411
|
track = null;
|
|
412
|
+
playlist = null;
|
|
324
413
|
engine.setNowPlaying?.(null);
|
|
325
414
|
return;
|
|
326
415
|
}
|
|
327
416
|
return item.subscribe((s) => {
|
|
417
|
+
playlist = s;
|
|
328
418
|
track = s.status === 'playing' ? s.track : null;
|
|
329
419
|
engine.setNowPlaying?.(
|
|
330
420
|
s.status === 'playing' && s.track ? nowPlayingLine(s.track) : null,
|
|
331
|
-
s.index
|
|
421
|
+
s.index,
|
|
422
|
+
s.status === 'playing' ? s.position : null,
|
|
423
|
+
s.status === 'playing' ? s.duration : null,
|
|
424
|
+
s.track?.title ?? null
|
|
332
425
|
);
|
|
333
426
|
});
|
|
334
427
|
});
|
|
@@ -498,6 +591,11 @@
|
|
|
498
591
|
closeMenu(false);
|
|
499
592
|
if (!engine.list().length) return;
|
|
500
593
|
}
|
|
594
|
+
// Paused: the trigger resumes in place (the scape keeps its position).
|
|
595
|
+
if (paused) {
|
|
596
|
+
attemptResume();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
501
599
|
if (playing) {
|
|
502
600
|
engine.stop();
|
|
503
601
|
return;
|
|
@@ -544,6 +642,46 @@
|
|
|
544
642
|
engine.setVolume(v / 100);
|
|
545
643
|
}
|
|
546
644
|
|
|
645
|
+
// --- now-playing row transport (jatui-153tz) -----------------------------
|
|
646
|
+
/** A rejected engine.resume() sets the transient error state, like play. */
|
|
647
|
+
function attemptResume() {
|
|
648
|
+
void engine
|
|
649
|
+
.resume()
|
|
650
|
+
.then(() => {
|
|
651
|
+
playError = false;
|
|
652
|
+
})
|
|
653
|
+
.catch(() => {
|
|
654
|
+
playError = true;
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** The row's play/pause: pauses without closing the menu; resumes a
|
|
659
|
+
* paused scape. The block only exists while playing or paused. */
|
|
660
|
+
function onPlayPauseClick() {
|
|
661
|
+
if (paused) attemptResume();
|
|
662
|
+
else if (playing) engine.pause();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Previous track. A remote tab routes the command to the leader. */
|
|
666
|
+
function onPrevClick() {
|
|
667
|
+
const e = engine as SharedAmbience;
|
|
668
|
+
if (isRemote && typeof e.prevTrack === 'function' && currentId) {
|
|
669
|
+
e.prevTrack(currentId);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (isPlaylistScape(currentItem)) currentItem.prev();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Next track (skip). A remote tab routes the command to the leader. */
|
|
676
|
+
function onNextClick() {
|
|
677
|
+
const e = engine as SharedAmbience;
|
|
678
|
+
if (isRemote && typeof e.skipTrack === 'function' && currentId) {
|
|
679
|
+
e.skipTrack(currentId);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (isPlaylistScape(currentItem)) currentItem.skip();
|
|
683
|
+
}
|
|
684
|
+
|
|
547
685
|
// --- wheel volume over the open surfaces (jatui-xomny) -------------------
|
|
548
686
|
// While the menu (or one of its submenus) is open the wheel adjusts volume
|
|
549
687
|
// instead of scrolling the page — EXCEPT over a scrollable list that can
|
|
@@ -743,6 +881,13 @@
|
|
|
743
881
|
transition-colors duration-[var(--motion-instant,80ms)]
|
|
744
882
|
hover:bg-base-content/10 focus-visible:bg-base-content/10
|
|
745
883
|
focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary`;
|
|
884
|
+
|
|
885
|
+
/** The now-playing row's icon transport buttons (prev / play-pause / next). */
|
|
886
|
+
const TRANSPORT_CLASS = `inline-flex size-8 items-center justify-center rounded-full
|
|
887
|
+
text-base-content/80 transition-colors duration-[var(--motion-instant,80ms)]
|
|
888
|
+
hover:bg-base-content/10 hover:text-base-content
|
|
889
|
+
focus-visible:bg-base-content/10 focus-visible:outline-2
|
|
890
|
+
focus-visible:-outline-offset-2 focus-visible:outline-primary`;
|
|
746
891
|
</script>
|
|
747
892
|
|
|
748
893
|
<div bind:this={wrapRef} class="relative inline-flex shrink-0">
|
|
@@ -835,6 +980,94 @@
|
|
|
835
980
|
onkeydown={onMenuKeydown}
|
|
836
981
|
onscroll={() => closeSubmenu(false)}
|
|
837
982
|
>
|
|
983
|
+
<!-- Now playing (jatui-153tz): what's sounding, with transport, ABOVE
|
|
984
|
+
the volume row. Playlist scapes get the track line, progress and
|
|
985
|
+
prev/play-pause/next; generative scapes get play/pause only.
|
|
986
|
+
Paused keeps the row (its play button is the resume); stopped
|
|
987
|
+
hides it. -->
|
|
988
|
+
{#if showNowPlaying}
|
|
989
|
+
<div role="none" class="now-playing mb-1 border-b border-base-content/10 px-2 pb-2 pt-1.5">
|
|
990
|
+
{#if npTitle}
|
|
991
|
+
<p class="block truncate text-sm font-medium" title={npTitle}>{npTitle}</p>
|
|
992
|
+
{/if}
|
|
993
|
+
{#if npAttrib}
|
|
994
|
+
<p class="text-2xs text-base-content/55 block truncate" title={npAttrib}>{npAttrib}</p>
|
|
995
|
+
{/if}
|
|
996
|
+
{#if npDuration > 0}
|
|
997
|
+
<div
|
|
998
|
+
class="now-progress mt-1.5 h-1 w-full overflow-hidden rounded-full bg-base-content/15"
|
|
999
|
+
role="progressbar"
|
|
1000
|
+
aria-label="Track progress"
|
|
1001
|
+
aria-valuemin={0}
|
|
1002
|
+
aria-valuemax={Math.round(npDuration)}
|
|
1003
|
+
aria-valuenow={Math.round(npPosition)}
|
|
1004
|
+
aria-valuetext="{formatTrackTime(npPosition)} of {formatTrackTime(npDuration)}"
|
|
1005
|
+
>
|
|
1006
|
+
<div class="now-progress-fill h-full rounded-full bg-primary" style:width="{npPercent}%"></div>
|
|
1007
|
+
</div>
|
|
1008
|
+
<div class="mt-1 flex justify-between text-2xs text-base-content/50 tabular-nums">
|
|
1009
|
+
<span>{formatTrackTime(npPosition)}</span>
|
|
1010
|
+
<span>{formatTrackTime(npDuration)}</span>
|
|
1011
|
+
</div>
|
|
1012
|
+
{/if}
|
|
1013
|
+
<div class="mt-1.5 flex items-center justify-center gap-1">
|
|
1014
|
+
{#if currentIsPlaylist}
|
|
1015
|
+
<button
|
|
1016
|
+
type="button"
|
|
1017
|
+
tabindex="-1"
|
|
1018
|
+
data-ambience-nav
|
|
1019
|
+
aria-label="Previous track"
|
|
1020
|
+
class={TRANSPORT_CLASS}
|
|
1021
|
+
onclick={onPrevClick}
|
|
1022
|
+
>
|
|
1023
|
+
<!-- skip-back, lucide geometry -->
|
|
1024
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
|
|
1025
|
+
<polygon points="19 20 9 12 19 4 19 20" />
|
|
1026
|
+
<line x1="5" y1="19" x2="5" y2="5" />
|
|
1027
|
+
</svg>
|
|
1028
|
+
</button>
|
|
1029
|
+
{/if}
|
|
1030
|
+
<button
|
|
1031
|
+
type="button"
|
|
1032
|
+
tabindex="-1"
|
|
1033
|
+
data-ambience-nav
|
|
1034
|
+
aria-label={playing ? 'Pause' : 'Play'}
|
|
1035
|
+
class="{TRANSPORT_CLASS} text-primary"
|
|
1036
|
+
onclick={onPlayPauseClick}
|
|
1037
|
+
>
|
|
1038
|
+
{#if playing}
|
|
1039
|
+
<!-- pause, lucide geometry -->
|
|
1040
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
|
|
1041
|
+
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
1042
|
+
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
1043
|
+
</svg>
|
|
1044
|
+
{:else}
|
|
1045
|
+
<!-- play, lucide geometry -->
|
|
1046
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
|
|
1047
|
+
<polygon points="6 3 20 12 6 21 6 3" />
|
|
1048
|
+
</svg>
|
|
1049
|
+
{/if}
|
|
1050
|
+
</button>
|
|
1051
|
+
{#if currentIsPlaylist}
|
|
1052
|
+
<button
|
|
1053
|
+
type="button"
|
|
1054
|
+
tabindex="-1"
|
|
1055
|
+
data-ambience-nav
|
|
1056
|
+
aria-label="Next track"
|
|
1057
|
+
class={TRANSPORT_CLASS}
|
|
1058
|
+
onclick={onNextClick}
|
|
1059
|
+
>
|
|
1060
|
+
<!-- skip-forward, lucide geometry -->
|
|
1061
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
|
|
1062
|
+
<polygon points="5 4 15 12 5 20 5 4" />
|
|
1063
|
+
<line x1="19" y1="5" x2="19" y2="19" />
|
|
1064
|
+
</svg>
|
|
1065
|
+
</button>
|
|
1066
|
+
{/if}
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
{/if}
|
|
1070
|
+
|
|
838
1071
|
<!-- Volume: a real slider in the roving set; it owns its own arrows. -->
|
|
839
1072
|
<div role="none" bind:this={volumeRef} class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
|
|
840
1073
|
<div class="mb-1.5 flex items-center justify-between">
|
|
@@ -1067,4 +1300,20 @@
|
|
|
1067
1300
|
opacity: 0;
|
|
1068
1301
|
}
|
|
1069
1302
|
}
|
|
1303
|
+
|
|
1304
|
+
/*
|
|
1305
|
+
* The now-playing progress (jatui-153tz): the fill updates on the playlist
|
|
1306
|
+
* state's ~250 ms tick cadence; a width transition of the same span smooths
|
|
1307
|
+
* the steps into a glide. Reduced motion drops the interpolation — the bar
|
|
1308
|
+
* still advances, in instant jumps; the position itself is never animated.
|
|
1309
|
+
*/
|
|
1310
|
+
.now-progress-fill {
|
|
1311
|
+
transition: width 250ms linear;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1315
|
+
.now-progress-fill {
|
|
1316
|
+
transition: none;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1070
1319
|
</style>
|
|
@@ -22,6 +22,15 @@
|
|
|
22
22
|
* real `<a target="_blank">` menu items with an external-link glyph:
|
|
23
23
|
* the station's own site or player opens in a new tab. Nothing is
|
|
24
24
|
* relayed — we never proxy a third-party stream.
|
|
25
|
+
* - A playing (or paused) scape shows a now-playing block above the
|
|
26
|
+
* volume row (jatui-153tz): for a playlist — the track title, its
|
|
27
|
+
* composer · license attribution, an elapsed/total progress bar and a
|
|
28
|
+
* prev / play-pause / next transport (prev restarts the track when more
|
|
29
|
+
* than 3 s are in, else goes back one; play-pause freezes the station
|
|
30
|
+
* without closing the menu; next skips). Generative scapes (noise /
|
|
31
|
+
* focus) get the play/pause control only. The transport joins the
|
|
32
|
+
* menu's roving order; a paused row stays visible (its play button is
|
|
33
|
+
* the resume) and a stopped one hides.
|
|
25
34
|
* - A playing playlist shows its current track's attribution (title,
|
|
26
35
|
* composer, license) under the playlist item and its section row.
|
|
27
36
|
* - With a menu or submenu open, the mouse wheel over that surface
|
|
@@ -59,12 +68,17 @@ export type AmbienceScapeGroup = AmbienceSection;
|
|
|
59
68
|
export declare function groupScapes(items: AmbienceItem[], groups?: ReadonlyArray<ScapeGroup | AmbienceGroupDef>): AmbienceScapeGroup[];
|
|
60
69
|
/** "Gymnopédie No. 1 — Erik Satie · Public domain": a playing track's line. */
|
|
61
70
|
export declare function nowPlayingLine(track: PlaylistTrack | null | undefined): string;
|
|
71
|
+
/** "3:07" — m:ss for the now-playing row's elapsed/total (jatui-153tz). */
|
|
72
|
+
export declare function formatTrackTime(seconds: number | null | undefined): string;
|
|
73
|
+
/** The now-playing progress fill, 0–100, clamped; 0 without a duration. */
|
|
74
|
+
export declare function progressPercent(position: number, duration: number): number;
|
|
62
75
|
/**
|
|
63
76
|
* The live label — "Ambience: Hibiscus, playing" while sounding,
|
|
64
|
-
* "Ambience: Hibiscus,
|
|
65
|
-
* playing, "Ambience: off" before
|
|
77
|
+
* "Ambience: Hibiscus, paused" while paused, "Ambience: Hibiscus, stopped"
|
|
78
|
+
* once a scape has been selected but is not playing, "Ambience: off" before
|
|
79
|
+
* anything was selected.
|
|
66
80
|
*/
|
|
67
|
-
export declare function ambienceLiveLabel(currentLabel: string | null, playing: boolean): string;
|
|
81
|
+
export declare function ambienceLiveLabel(currentLabel: string | null, playing: boolean, paused?: boolean): string;
|
|
68
82
|
/** Transient failure label — a rejected engine.play() (gesture probe,
|
|
69
83
|
* context suspend, unknown scape) must never surface as an unhandled
|
|
70
84
|
* rejection OR as silence: the trigger visibly fails to respond, so the
|