@joewinke/jatui 0.58.0 → 0.60.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 +1 -1
- package/dist/ambience/README.md +9 -2
- package/dist/ambience/default.d.ts +3 -0
- package/dist/ambience/default.js +5 -1
- package/dist/ambience/engine.d.ts +2 -0
- package/dist/ambience/engine.js +101 -4
- package/dist/ambience/index.d.ts +3 -1
- package/dist/ambience/index.js +5 -1
- package/dist/ambience/persist.d.ts +2 -0
- package/dist/ambience/scapes/playlist.d.ts +19 -0
- package/dist/ambience/scapes/playlist.js +95 -4
- package/dist/ambience/shared.d.ts +164 -0
- package/dist/ambience/shared.js +744 -0
- package/dist/ambience/types.d.ts +23 -0
- package/dist/components/AmbienceToggle.svelte +281 -9
- package/dist/components/AmbienceToggle.svelte.d.ts +20 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
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;
|
|
@@ -121,4 +140,8 @@ export interface AmbienceOptions {
|
|
|
121
140
|
isGesture?: () => boolean;
|
|
122
141
|
/** Context factory, injectable for tests (default: `new AudioContext()`). */
|
|
123
142
|
createContext?: () => AudioContextLike;
|
|
143
|
+
/** Cross-tab shared player (jatui-lm1mf): one tab (the leader) owns the
|
|
144
|
+
* AudioContext and plays; every other tab renders the same live state and
|
|
145
|
+
* sends commands. See `shared.ts`. */
|
|
146
|
+
shared?: boolean;
|
|
124
147
|
}
|
|
@@ -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,14 +234,15 @@
|
|
|
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';
|
|
237
|
+
import type { SharedAmbience, SharedAmbienceState } from '../ambience/shared';
|
|
204
238
|
|
|
205
239
|
let {
|
|
206
240
|
engine,
|
|
207
241
|
compact = false,
|
|
208
242
|
placement = 'bottom-end'
|
|
209
243
|
}: {
|
|
210
|
-
/** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. */
|
|
211
|
-
engine: Ambience;
|
|
244
|
+
/** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. With the cross-tab shared player (jatui-lm1mf, `createAmbience({ shared: true })`) a plain `Ambience` is also accepted: the shared now-playing line is only read when the engine publishes one. */
|
|
245
|
+
engine: Ambience | SharedAmbience;
|
|
212
246
|
/** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
|
|
213
247
|
compact?: boolean;
|
|
214
248
|
/** Which way the menu opens from the trigger. Auto-flips vertically when the viewport has no room on that side. */
|
|
@@ -227,6 +261,8 @@
|
|
|
227
261
|
let playing = $state(false);
|
|
228
262
|
let currentId = $state<string | null>(null);
|
|
229
263
|
let volume = $state(0.6);
|
|
264
|
+
/** True while the engine is paused (pause() — frozen in place, resumable). */
|
|
265
|
+
let paused = $state(false);
|
|
230
266
|
let items = $state<AmbienceItem[]>([]);
|
|
231
267
|
let open = $state(false);
|
|
232
268
|
// svelte-ignore state_referenced_locally -- only the seed; resolvePlacement() reads the live prop
|
|
@@ -259,6 +295,20 @@
|
|
|
259
295
|
|
|
260
296
|
let playError = $state(false);
|
|
261
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);
|
|
302
|
+
/** The shared player's now-playing line (jatui-lm1mf): published by the
|
|
303
|
+
* leader tab, so a remote tab shows the same track attribution without
|
|
304
|
+
* owning the playlist instance. Null on a plain engine. */
|
|
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);
|
|
262
312
|
|
|
263
313
|
// Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
|
|
264
314
|
// the pending partial notch between wheel events so trackpad pixel deltas
|
|
@@ -271,13 +321,45 @@
|
|
|
271
321
|
const currentLabel = $derived(currentItem?.label ?? currentId);
|
|
272
322
|
const groups = $derived(groupScapes(items, engine.groups?.()));
|
|
273
323
|
const openSection = $derived(groups.find((g) => g.group === subGroup) ?? null);
|
|
274
|
-
const trackLine = $derived(
|
|
324
|
+
const trackLine = $derived(
|
|
325
|
+
playing || paused ? (sharedNow ?? nowPlayingLine(track)) : ''
|
|
326
|
+
);
|
|
275
327
|
const liveLabel = $derived(
|
|
276
|
-
playError
|
|
328
|
+
playError
|
|
329
|
+
? PLAY_ERROR_LABEL
|
|
330
|
+
: ambienceLiveLabel(currentLabel, playing, paused)
|
|
277
331
|
);
|
|
278
332
|
const disabled = $derived(items.length === 0);
|
|
279
333
|
const sliderValue = $derived(Math.round(volume * 100));
|
|
280
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
|
+
|
|
281
363
|
const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
|
|
282
364
|
const ICON_SIZE = $derived(compact ? 18 : 20);
|
|
283
365
|
|
|
@@ -290,6 +372,7 @@
|
|
|
290
372
|
$effect(() => {
|
|
291
373
|
return engine.subscribe((s) => {
|
|
292
374
|
playing = s.playing;
|
|
375
|
+
paused = s.paused;
|
|
293
376
|
currentId = s.currentId;
|
|
294
377
|
volume = s.volume;
|
|
295
378
|
items = snapshot();
|
|
@@ -298,15 +381,48 @@
|
|
|
298
381
|
});
|
|
299
382
|
});
|
|
300
383
|
|
|
301
|
-
// The
|
|
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.
|
|
386
|
+
$effect(() => {
|
|
387
|
+
const e = engine as SharedAmbience;
|
|
388
|
+
if (typeof e.subscribeShared !== 'function') {
|
|
389
|
+
sharedNow = null;
|
|
390
|
+
sharedRole = null;
|
|
391
|
+
sharedTitle = null;
|
|
392
|
+
sharedPosition = null;
|
|
393
|
+
sharedDuration = null;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
return e.subscribeShared((s) => {
|
|
397
|
+
sharedNow = s.nowPlaying;
|
|
398
|
+
sharedRole = s.role;
|
|
399
|
+
sharedTitle = s.trackTitle;
|
|
400
|
+
sharedPosition = s.trackPosition;
|
|
401
|
+
sharedDuration = s.trackDuration;
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
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.
|
|
302
408
|
$effect(() => {
|
|
303
409
|
const item = currentItem;
|
|
304
410
|
if (!isPlaylistScape(item)) {
|
|
305
411
|
track = null;
|
|
412
|
+
playlist = null;
|
|
413
|
+
engine.setNowPlaying?.(null);
|
|
306
414
|
return;
|
|
307
415
|
}
|
|
308
416
|
return item.subscribe((s) => {
|
|
417
|
+
playlist = s;
|
|
309
418
|
track = s.status === 'playing' ? s.track : null;
|
|
419
|
+
engine.setNowPlaying?.(
|
|
420
|
+
s.status === 'playing' && s.track ? nowPlayingLine(s.track) : null,
|
|
421
|
+
s.index,
|
|
422
|
+
s.status === 'playing' ? s.position : null,
|
|
423
|
+
s.status === 'playing' ? s.duration : null,
|
|
424
|
+
s.track?.title ?? null
|
|
425
|
+
);
|
|
310
426
|
});
|
|
311
427
|
});
|
|
312
428
|
|
|
@@ -475,6 +591,11 @@
|
|
|
475
591
|
closeMenu(false);
|
|
476
592
|
if (!engine.list().length) return;
|
|
477
593
|
}
|
|
594
|
+
// Paused: the trigger resumes in place (the scape keeps its position).
|
|
595
|
+
if (paused) {
|
|
596
|
+
attemptResume();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
478
599
|
if (playing) {
|
|
479
600
|
engine.stop();
|
|
480
601
|
return;
|
|
@@ -521,6 +642,46 @@
|
|
|
521
642
|
engine.setVolume(v / 100);
|
|
522
643
|
}
|
|
523
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
|
+
|
|
524
685
|
// --- wheel volume over the open surfaces (jatui-xomny) -------------------
|
|
525
686
|
// While the menu (or one of its submenus) is open the wheel adjusts volume
|
|
526
687
|
// instead of scrolling the page — EXCEPT over a scrollable list that can
|
|
@@ -720,6 +881,13 @@
|
|
|
720
881
|
transition-colors duration-[var(--motion-instant,80ms)]
|
|
721
882
|
hover:bg-base-content/10 focus-visible:bg-base-content/10
|
|
722
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`;
|
|
723
891
|
</script>
|
|
724
892
|
|
|
725
893
|
<div bind:this={wrapRef} class="relative inline-flex shrink-0">
|
|
@@ -812,6 +980,94 @@
|
|
|
812
980
|
onkeydown={onMenuKeydown}
|
|
813
981
|
onscroll={() => closeSubmenu(false)}
|
|
814
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
|
+
|
|
815
1071
|
<!-- Volume: a real slider in the roving set; it owns its own arrows. -->
|
|
816
1072
|
<div role="none" bind:this={volumeRef} class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
|
|
817
1073
|
<div class="mb-1.5 flex items-center justify-between">
|
|
@@ -1044,4 +1300,20 @@
|
|
|
1044
1300
|
opacity: 0;
|
|
1045
1301
|
}
|
|
1046
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
|
+
}
|
|
1047
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
|
|
@@ -128,9 +142,10 @@ export declare function wheelVolumeSteps(acc: {
|
|
|
128
142
|
*/
|
|
129
143
|
export declare function volumeAfterSteps(volume: number, steps: number, stepPct?: number): number;
|
|
130
144
|
import type { Ambience } from '../ambience/types';
|
|
145
|
+
import type { SharedAmbience } from '../ambience/shared';
|
|
131
146
|
type $$ComponentProps = {
|
|
132
|
-
/** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. */
|
|
133
|
-
engine: Ambience;
|
|
147
|
+
/** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. With the cross-tab shared player (jatui-lm1mf, `createAmbience({ shared: true })`) a plain `Ambience` is also accepted: the shared now-playing line is only read when the engine publishes one. */
|
|
148
|
+
engine: Ambience | SharedAmbience;
|
|
134
149
|
/** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
|
|
135
150
|
compact?: boolean;
|
|
136
151
|
/** Which way the menu opens from the trigger. Auto-flips vertically when the viewport has no room on that side. */
|
package/dist/index.d.ts
CHANGED
|
@@ -411,8 +411,9 @@ export { default as ImageReveal } from './components/ImageReveal.svelte';
|
|
|
411
411
|
export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize, imageRevealTone, IMAGE_REVEAL_SIZES, IMAGE_REVEAL_RADIUS } from './components/imageReveal';
|
|
412
412
|
export type { ImageRevealPhase, ImageRevealShape, ImageRevealSizeName, ImageRevealToneName, ImageRevealState } from './components/imageReveal';
|
|
413
413
|
export { default as AmbienceToggle, AMBIENCE_GROUP_ORDER } from './components/AmbienceToggle.svelte';
|
|
414
|
-
export { createAmbience, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
|
|
414
|
+
export { createAmbience, createAmbienceLocal, createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
|
|
415
415
|
export type { DefaultAmbienceOptions, AmbienceSection } from './ambience';
|
|
416
|
+
export type { SharedAmbience, SharedAmbienceState, SharedAmbienceOptions } from './ambience';
|
|
416
417
|
export type { Ambience, AmbienceGroupDef, AmbienceItem, AmbienceLink, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './ambience';
|
|
417
418
|
export { createPlaylistScape } from './ambience';
|
|
418
419
|
export { isPlaylistScape, trackAttribution } from './ambience';
|
package/dist/index.js
CHANGED
|
@@ -460,7 +460,7 @@ export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize
|
|
|
460
460
|
// navigation; createDefaultAmbience() pre-registers every shipped scape
|
|
461
461
|
// (hibiscus, focus, brown/pink/white noise, rain, waves).
|
|
462
462
|
export { default as AmbienceToggle, AMBIENCE_GROUP_ORDER } from './components/AmbienceToggle.svelte';
|
|
463
|
-
export { createAmbience, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
|
|
463
|
+
export { createAmbience, createAmbienceLocal, createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
|
|
464
464
|
// Playlist scape (phase 2) — per-host, never in DEFAULT_SCAPES: build with
|
|
465
465
|
// createPlaylistScape() and register on the engine yourself.
|
|
466
466
|
export { createPlaylistScape } from './ambience';
|