@joewinke/jatui 0.59.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 CHANGED
@@ -333,7 +333,7 @@ const rec = createAudioRecorder({
333
333
 
334
334
  ### Ambience (subpath: `jatui/ambience`, components on the root)
335
335
 
336
- `createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = volume + one row per section (Radio / Focus / Noise / Meditation … — host order via `groups: Array<ScapeGroup | {id, label}>`, default radio/focus/noise/meditation/nature/music, unlisted groups appended), each opening a submenu (→/Enter/Space open, ←/Esc close; ContextMenu submenu pattern). `radioStation({id, label, href})` registers an OUTBOUND LINK item (new tab, `rel="noopener noreferrer"`, external glyph, http(s) only) — streams are never relayed. `createPlaylistScape({ …, group? })` (default `'music'`); a playing playlist shows title — composer · license. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
336
+ `createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = a now-playing row above volume (playing/paused only; playlists: track title — composer · license, elapsed/total progress bar, prev / play-pause / next transport — `prev()` restarts the track when >3 s are in, else goes back one; play-pause uses engine `pause()`/`resume()` which freeze the scape in place — generative scapes get play/pause only) + one row per section (Radio / Focus / Noise / Meditation … — host order via `groups: Array<ScapeGroup | {id, label}>`, default radio/focus/noise/meditation/nature/music, unlisted groups appended), each opening a submenu (→/Enter/Space open, ←/Esc close; ContextMenu submenu pattern). `radioStation({id, label, href})` registers an OUTBOUND LINK item (new tab, `rel="noopener noreferrer"`, external glyph, http(s) only) — streams are never relayed. `createPlaylistScape({ …, group? })` (default `'music'`); a playing playlist shows title — composer · license and exposes `state().position/duration` + `skip()`/`prev()`. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Shared mode: remotes render the leader's published now-playing parts (`trackTitle/trackPosition/trackDuration`) and route pause/resume/skip/prev via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
337
337
 
338
338
  ### Sound (first-class sibling of motion)
339
339
 
@@ -120,12 +120,19 @@ scape.subscribe((s) => {
120
120
  // s.status: 'idle' | 'loading' | 'playing' | 'error'
121
121
  // s.track: { title, composer, performer?, license, sourceUrl? } | null
122
122
  // s.index, s.count, s.error?
123
+ // s.position, s.duration — elapsed/total seconds of the current track
124
+ // (0 while unknown; refreshed on the ~250 ms engine tick — jatui-153tz)
123
125
  });
124
- scape.skip(); // crossfade to the next track now
126
+ scape.skip(); // crossfade to the next track now
127
+ scape.prev(); // restart the track when >3 s are in, else the previous track
125
128
  scape.setShuffle(true);
126
129
  scape.tracks(); // resolved list, or null until the manifest resolved
127
130
  ```
128
131
 
129
132
  Cast a scape from `ambience.list()` to `PlaylistScape` in the toggle to reach
130
133
  it. The toggle renders the attribution line — `composer · performer · license`
131
- — linked to `sourceUrl` while a playlist scape is playing.
134
+ — linked to `sourceUrl` while a playlist scape is playing, and (jatui-153tz) a
135
+ now-playing row above the volume with the track, a progress bar fed by
136
+ `position`/`duration`, and a prev / play-pause / next transport. The engine's
137
+ `pause()`/`resume()` freeze the scape in place — the playlist pauses its media
138
+ elements and keeps the track position.
@@ -12,6 +12,8 @@ export declare const TIMING: Readonly<{
12
12
  RESUME_FADE: 4;
13
13
  /** setVolume ramp (s). */
14
14
  VOLUME_RAMP: 0.3;
15
+ /** pause() fade-out before the scape freezes (s). */
16
+ PAUSE_FADE: 0.15;
15
17
  /** The fade handed to instance.stop() on kill — 0 = immediate. */
16
18
  KILL_FADE: 0;
17
19
  /** After a scape's stop fade, its un-released lookahead cbs are retired
@@ -27,6 +27,8 @@ export const TIMING = Object.freeze({
27
27
  RESUME_FADE: 4,
28
28
  /** setVolume ramp (s). */
29
29
  VOLUME_RAMP: 0.3,
30
+ /** pause() fade-out before the scape freezes (s). */
31
+ PAUSE_FADE: 0.15,
30
32
  /** The fade handed to instance.stop() on kill — 0 = immediate. */
31
33
  KILL_FADE: 0,
32
34
  /** After a scape's stop fade, its un-released lookahead cbs are retired
@@ -50,6 +52,12 @@ export function createAmbience(options = {}) {
50
52
  let volume = clamp(typeof persisted.volume === 'number' ? persisted.volume : (options.defaultVolume ?? 0.6));
51
53
  let currentId = persisted.lastScape ?? null;
52
54
  let playing = false;
55
+ /** True between pause() and resume()/play()/stop(): the instance stays
56
+ * built and frozen (playlist position kept), the context suspended. */
57
+ let paused = false;
58
+ /** The deferred freeze at the end of pause()'s fade — a resume()/play()
59
+ * inside the window aborts it. */
60
+ let pauseTimer = null;
53
61
  let ctx = null;
54
62
  let chain = null;
55
63
  let active = null;
@@ -62,7 +70,7 @@ export function createAmbience(options = {}) {
62
70
  const breathSubs = new Set();
63
71
  const lookaheadCbs = new Set();
64
72
  function state() {
65
- return { currentId, playing, volume };
73
+ return { currentId, playing, paused, volume };
66
74
  }
67
75
  function emit() {
68
76
  const s = state();
@@ -145,8 +153,11 @@ export function createAmbience(options = {}) {
145
153
  // one is suspended at `await c.resume()` invalidates it — the loser
146
154
  // returns without building or starting anything.
147
155
  const gen = ++playGen;
148
- // Inside the post-stop close window: cancel the close, fade back in.
149
- const resuming = closeTimer !== null;
156
+ const wasPaused = paused;
157
+ paused = false; // a play() is a fresh start — never a continuation
158
+ // Inside the post-stop close window (or resuming from a paused scape):
159
+ // cancel the close / unfreeze, and fade the bus back in.
160
+ const resuming = closeTimer !== null || wasPaused;
150
161
  if (closeTimer !== null) {
151
162
  clearTimeout(closeTimer);
152
163
  closeTimer = null;
@@ -201,7 +212,15 @@ export function createAmbience(options = {}) {
201
212
  }
202
213
  function stop() {
203
214
  playGen++; // invalidate any in-flight play()
204
- if (!ctx || !playing) {
215
+ if (pauseTimer) {
216
+ clearTimeout(pauseTimer);
217
+ pauseTimer = null;
218
+ }
219
+ const wasPaused = paused;
220
+ paused = false;
221
+ // A paused scape still owns a live (frozen) instance + context — its
222
+ // stop must tear them down like a playing stop would.
223
+ if (!ctx || (!playing && !wasPaused)) {
205
224
  playing = false;
206
225
  emit();
207
226
  return;
@@ -223,12 +242,88 @@ export function createAmbience(options = {}) {
223
242
  }, TIMING.CLOSE_DELAY_MS);
224
243
  emit();
225
244
  }
245
+ function pause() {
246
+ if (!playing)
247
+ return;
248
+ playing = false;
249
+ paused = true;
250
+ emit();
251
+ if (!ctx || !chain || !active)
252
+ return;
253
+ // A short fade so the freeze is click-free, then the scape freezes in
254
+ // place (media elements) and the context suspends — generative scapes
255
+ // stop evolving and the audio thread rests.
256
+ const now = ctx.currentTime;
257
+ const g = chain.bus.gain;
258
+ g.cancelScheduledValues(now);
259
+ g.setValueAtTime(g.value, now);
260
+ g.linearRampToValueAtTime(0, now + TIMING.PAUSE_FADE);
261
+ if (pauseTimer)
262
+ clearTimeout(pauseTimer);
263
+ pauseTimer = setTimeout(() => {
264
+ pauseTimer = null;
265
+ // A resume()/play()/stop() inside the fade window aborted the pause.
266
+ if (!paused || playing || !active || !ctx)
267
+ return;
268
+ try {
269
+ active.inst.pause?.();
270
+ }
271
+ catch {
272
+ /* a broken scape must not take the engine down */
273
+ }
274
+ try {
275
+ void ctx.suspend();
276
+ }
277
+ catch {
278
+ /* already closed */
279
+ }
280
+ }, TIMING.PAUSE_FADE * 1000 + 20);
281
+ }
282
+ async function resume() {
283
+ if (playing || !currentId || !paused)
284
+ return; // only a paused scape resumes
285
+ if (!isGesture())
286
+ throw new Error('resume() must be called from a user gesture');
287
+ const gen = ++playGen; // a stop()/kill() during the resume invalidates it
288
+ paused = false;
289
+ // Defensive: a paused engine always still owns its context + instance
290
+ // (kill/stop clear the paused flag) — bail rather than guess.
291
+ if (!ctx || !chain || !active) {
292
+ emit();
293
+ return;
294
+ }
295
+ if (ctx.state === 'suspended')
296
+ await ctx.resume();
297
+ if (gen !== playGen || !ctx || !chain || !active)
298
+ return; // superseded mid-resume
299
+ try {
300
+ active.inst.resume?.();
301
+ }
302
+ catch {
303
+ /* a broken scape must not take the engine down */
304
+ }
305
+ const g = chain.bus.gain;
306
+ g.cancelScheduledValues(ctx.currentTime);
307
+ g.setValueAtTime(g.value, ctx.currentTime);
308
+ g.linearRampToValueAtTime(1, ctx.currentTime + TIMING.VOLUME_RAMP);
309
+ // A pause did not stop the breath clock, but make sure it runs.
310
+ if (!breath.running)
311
+ breath.start(ctx.currentTime);
312
+ playing = true;
313
+ persist();
314
+ emit();
315
+ }
226
316
  function kill() {
227
317
  playGen++; // invalidate any in-flight play()
228
318
  if (closeTimer) {
229
319
  clearTimeout(closeTimer);
230
320
  closeTimer = null;
231
321
  }
322
+ if (pauseTimer) {
323
+ clearTimeout(pauseTimer);
324
+ pauseTimer = null;
325
+ }
326
+ paused = false;
232
327
  if (tick) {
233
328
  clearInterval(tick);
234
329
  tick = null;
@@ -298,6 +393,8 @@ export function createAmbience(options = {}) {
298
393
  },
299
394
  play,
300
395
  stop,
396
+ pause,
397
+ resume,
301
398
  kill,
302
399
  setVolume,
303
400
  get state() {
@@ -47,6 +47,9 @@ export declare const PLAYLIST: Readonly<{
47
47
  /** Start the next track when at most this much of the current track is
48
48
  * left ON TOP of the crossfade (s). */
49
49
  END_MARGIN: 0.25;
50
+ /** prev() restarts the current track when more than this many seconds are
51
+ * in; below it, it goes back one track (jatui-153tz). */
52
+ PREV_RESTART: 3;
50
53
  /** setParam('gain') ramp (s). */
51
54
  GAIN_RAMP: 0.15;
52
55
  /** Disconnect + pause this many ms after a stop fade ends — before the
@@ -84,6 +87,13 @@ export interface PlaylistState {
84
87
  index: number;
85
88
  /** Number of tracks in the resolved playlist (0 until resolved). */
86
89
  count: number;
90
+ /** Elapsed seconds of the current track (0 while none is sounding).
91
+ * Updated on the engine's ~250 ms lookahead tick — the cadence the
92
+ * toggle's progress bar renders at (jatui-153tz). */
93
+ position: number;
94
+ /** Total seconds of the current track (0 until metadata reports one —
95
+ * a stream without a duration stays 0). */
96
+ duration: number;
87
97
  /** Present on error. */
88
98
  error?: string;
89
99
  }
@@ -124,6 +134,11 @@ export interface PlaylistScape extends Scape {
124
134
  subscribe(fn: (s: PlaylistState) => void): () => void;
125
135
  /** Crossfade to the next track now. No-op while loading/released. */
126
136
  skip(): void;
137
+ /** Back one step (jatui-153tz): restart the current track when more than
138
+ * PLAYLIST.PREV_RESTART (3 s) are in, else crossfade to the previous
139
+ * track in the play order (at the very first track, restart). No-op
140
+ * while loading/released or mid-transition. */
141
+ prev(): void;
127
142
  /** Toggle shuffle; the current track stays, the rest reorder at once. */
128
143
  setShuffle(on: boolean): void;
129
144
  /** Crossfade to an absolute track index — used on leadership handover
@@ -8,6 +8,9 @@ export const PLAYLIST = Object.freeze({
8
8
  /** Start the next track when at most this much of the current track is
9
9
  * left ON TOP of the crossfade (s). */
10
10
  END_MARGIN: 0.25,
11
+ /** prev() restarts the current track when more than this many seconds are
12
+ * in; below it, it goes back one track (jatui-153tz). */
13
+ PREV_RESTART: 3,
11
14
  /** setParam('gain') ramp (s). */
12
15
  GAIN_RAMP: 0.15,
13
16
  /** Disconnect + pause this many ms after a stop fade ends — before the
@@ -43,7 +46,7 @@ export function createPlaylistScape(options) {
43
46
  }
44
47
  }
45
48
  /** Shared UI snapshot + subscribers. Only the latest build writes it. */
46
- let snap = { status: 'idle', track: null, index: 0, count: 0 };
49
+ let snap = { status: 'idle', track: null, index: 0, count: 0, position: 0, duration: 0 };
47
50
  const subs = new Set();
48
51
  /** Handle to the most recent build — skip()/setShuffle() route here. */
49
52
  let latest = null;
@@ -178,6 +181,12 @@ export function createPlaylistScape(options) {
178
181
  if (slot.trackIndex < 0)
179
182
  return;
180
183
  const el = slot.el;
184
+ // Live progress for the toggle's now-playing row (jatui-153tz):
185
+ // elapsed/total on the ~250 ms tick cadence the bar renders at.
186
+ const position = Number.isFinite(el.currentTime) ? Math.max(0, el.currentTime) : 0;
187
+ const duration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : 0;
188
+ if (snap.position !== position || snap.duration !== duration)
189
+ setSnap({ position, duration });
181
190
  const d = el.duration;
182
191
  // Stateless per-tick check (no while-loop to re-anchor): at most ONE
183
192
  // transition per tick, always anchored at THIS tick's now, so a
@@ -274,7 +283,15 @@ export function createPlaylistScape(options) {
274
283
  catch {
275
284
  onTrackFailed();
276
285
  }
277
- setSnap({ status: 'playing', track, index: pos, count: resolved.length, error: undefined });
286
+ setSnap({
287
+ status: 'playing',
288
+ track,
289
+ index: pos,
290
+ count: resolved.length,
291
+ position: 0,
292
+ duration: 0,
293
+ error: undefined
294
+ });
278
295
  preloadNext();
279
296
  }
280
297
  function quietSlot(slot) {
@@ -357,6 +374,8 @@ export function createPlaylistScape(options) {
357
374
  track: null,
358
375
  index: 0,
359
376
  count: resolved?.length ?? 0,
377
+ position: 0,
378
+ duration: 0,
360
379
  error: undefined
361
380
  });
362
381
  const f = Math.max(0, fade);
@@ -432,7 +451,9 @@ export function createPlaylistScape(options) {
432
451
  error: message,
433
452
  track: null,
434
453
  index: 0,
435
- count: resolved?.length ?? 0
454
+ count: resolved?.length ?? 0,
455
+ position: 0,
456
+ duration: 0
436
457
  });
437
458
  const now = ctx.currentTime;
438
459
  const g = mix.gain;
@@ -461,6 +482,57 @@ export function createPlaylistScape(options) {
461
482
  if (ni !== -1)
462
483
  startTrack(ni, 'cross');
463
484
  }
485
+ /** Back one step (jatui-153tz): > 3 s into the track restarts it, else
486
+ * the previous track in the play order (the first track restarts). */
487
+ function prevInternal() {
488
+ if (released || !started || order.length === 0 || active < 0)
489
+ return;
490
+ const slot = slots[active];
491
+ const cur = order[pos];
492
+ if (slot.trackIndex !== cur)
493
+ return; // mid-transition — the skip owns it
494
+ if (slot.el.currentTime > PLAYLIST.PREV_RESTART || pos === 0) {
495
+ // Restart the current track: a quick fade into the idle slot
496
+ // loaded with the same track, play order pointer unchanged.
497
+ startTrack(cur, 'ended');
498
+ }
499
+ else {
500
+ pos -= 1;
501
+ startTrack(order[pos], 'cross');
502
+ }
503
+ }
504
+ /** Engine pause(): freeze in place — both elements stop, the track and
505
+ * its position are kept for resume(). Gains/schedules stay as they are
506
+ * (the context suspension freezes the audio clock mid-ramp). */
507
+ function pauseInternal() {
508
+ if (released || !started)
509
+ return;
510
+ for (const slot of slots) {
511
+ try {
512
+ slot.el.pause();
513
+ }
514
+ catch {
515
+ /* element already unusable */
516
+ }
517
+ }
518
+ }
519
+ /** Engine resume(): the active element continues from where it paused.
520
+ * The idle (preloading) slot stays paused. */
521
+ function resumeInternal() {
522
+ if (released || !started || active < 0)
523
+ return;
524
+ const slot = slots[active];
525
+ if (slot.trackIndex < 0)
526
+ return;
527
+ try {
528
+ const p = slot.el.play();
529
+ if (p)
530
+ p.catch(() => { }); // the engine's failed-track path owns errors
531
+ }
532
+ catch {
533
+ /* element already unusable */
534
+ }
535
+ }
464
536
  /** Leadership handover (jatui-lm1mf): crossfade to an absolute track
465
537
  * index so the new leader tab picks up the position the old one left. */
466
538
  function startAtInternal(index) {
@@ -489,8 +561,9 @@ export function createPlaylistScape(options) {
489
561
  }
490
562
  wireLookahead();
491
563
  return {
492
- api: { start, stop, setParam },
564
+ api: { start, stop, setParam, pause: pauseInternal, resume: resumeInternal },
493
565
  skip: skipInternal,
566
+ prev: prevInternal,
494
567
  startAt: startAtInternal,
495
568
  reshuffle,
496
569
  isReleased: () => released
@@ -519,6 +592,10 @@ export function createPlaylistScape(options) {
519
592
  if (latest && !latest.isReleased())
520
593
  latest.skip();
521
594
  },
595
+ prev() {
596
+ if (latest && !latest.isReleased())
597
+ latest.prev();
598
+ },
522
599
  setShuffle(on) {
523
600
  shuffle = on;
524
601
  if (latest && !latest.isReleased())
@@ -1,3 +1,46 @@
1
+ /**
2
+ * The cross-tab shared player (jatui-lm1mf): exactly ONE tab owns the
3
+ * AudioContext and plays; every other tab is a REMOTE that renders the same
4
+ * live state and sends commands. Open MOS in ten tabs and the tab playing
5
+ * ambience is no longer unfindable — any tab's rail toggle shows and controls
6
+ * the one that's sounding.
7
+ *
8
+ * Leader election:
9
+ * - Web Locks API (`navigator.locks.request(lockName)`) when available —
10
+ * the lock IS the leadership, so the browser guarantees mutual exclusion.
11
+ * - Fallback (no Web Locks): a BroadcastChannel heartbeat. The leader
12
+ * heartbeats every HEARTBEAT_MS; a tab that hears no beat for STALE_MS
13
+ * may claim, guarded by a short window in which a live leader reasserts.
14
+ *
15
+ * Transport (always BroadcastChannel 'jatui:ambience'):
16
+ * - `state` / `heartbeat` — the leader publishes the live shared state
17
+ * (playing scape, volume, muted, track attribution, its tab id).
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).
21
+ * - `want` / `claim` — the heartbeat election.
22
+ * - `gone` — the leader's pagehide: remotes immediately mark the leader
23
+ * gone (playback stops — never a surprise autoplay in a background tab).
24
+ *
25
+ * Starting a scape in tab B while tab A plays makes B the leader: with
26
+ * Web Locks, B's user-gesture play STEALS the lock, A's held request promise
27
+ * rejects (AbortError) and A fades out — never two tabs audible. Playlist
28
+ * scapes hand over their track position (published by the leader, applied
29
+ * by the new leader via PlaylistScape.startAt).
30
+ *
31
+ * Resuming after the leader tab closes/reloads: playback is stopped and no
32
+ * remote autoplays; pressing play (the toggle's existing button — a user
33
+ * gesture, satisfying the autoplay policy) claims leadership and plays.
34
+ *
35
+ * Volume and mute persist in localStorage (shared by all tabs of the
36
+ * origin). The `storage` event is deliberately NOT the transport — it skips
37
+ * the writer tab; BroadcastChannel delivers to every OTHER tab.
38
+ *
39
+ * NO UI here. Hosts get everything through createAmbience({ shared: true }) —
40
+ * the Ambience interface is unchanged, so AmbienceToggle needs no structural
41
+ * changes beyond reading the shared now-playing line.
42
+ */
43
+ import { createAmbience as createLocalEngine } from './engine';
1
44
  import type { Ambience, AmbienceOptions } from './types';
2
45
  /** BroadcastChannel name — the ambience control channel for the origin. */
3
46
  export declare const AMBIENCE_CHANNEL = "jatui:ambience";
@@ -37,6 +80,18 @@ export interface SharedAmbienceState {
37
80
  /** Position of the published now-playing track in its playlist (0-based),
38
81
  * for leadership handover of playlist scapes. */
39
82
  trackIndex: number | null;
83
+ /** The published track's title, so a remote can show title and
84
+ * attribution on their own lines (jatui-153tz). */
85
+ trackTitle: string | null;
86
+ /** Elapsed seconds within the published track (leader-published at the
87
+ * playlist's ~250 ms state cadence); null when unknown. */
88
+ trackPosition: number | null;
89
+ /** Total seconds of the published track; null while unknown (no metadata
90
+ * yet, or a stream without a duration). */
91
+ trackDuration: number | null;
92
+ /** True while the leader is PAUSED (engine.pause() — the scape is frozen
93
+ * in place, not torn down; resume continues it). */
94
+ paused: boolean;
40
95
  }
41
96
  /** An Ambience whose playback is shared across tabs (see module doc). */
42
97
  export interface SharedAmbience extends Ambience {
@@ -48,8 +103,14 @@ export interface SharedAmbience extends Ambience {
48
103
  setMuted(muted: boolean): void;
49
104
  toggleMute(): void;
50
105
  /** Leader-side hook: the host publishes the playing track's attribution
51
- * (and playlist position) so remote tabs show it too. */
52
- setNowPlaying(line: string | null, trackIndex?: number | null): void;
106
+ * (and playlist position + progress) so remote tabs show it too.
107
+ * `position`/`duration`/`title` (jatui-153tz) feed the remote tabs'
108
+ * now-playing row; nulls mean unknown. */
109
+ setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null): void;
110
+ /** Now-playing-row transport (jatui-153tz): skip / go back one track on a
111
+ * playlist scape. A remote routes the command to the leader's instance. */
112
+ skipTrack(id: string): void;
113
+ prevTrack(id: string): void;
53
114
  /** Release the lock, stop the heartbeat and close the channel. The local
54
115
  * engine is killed if this tab was the leader. */
55
116
  destroy(): void;
@@ -95,3 +156,9 @@ export declare function createSharedAmbience(options?: SharedAmbienceOptions): S
95
156
  * Ambience interface (AmbienceToggle needs no changes beyond reading the
96
157
  * shared now-playing line). */
97
158
  export declare function createAmbience(options?: AmbienceOptions): Ambience;
159
+ /** The always-local engine factory — the barrel's `createAmbienceLocal`.
160
+ * The ambience barrel has re-exported this name since the shared player
161
+ * landed (jatui-lm1mf), but shared.ts never defined it: real ESM (the built
162
+ * dist, Node) failed to import the subpath at all (jatui-153tz rebuild). It
163
+ * is the plain engine: `shared` is never honored, whatever the options. */
164
+ export declare const createAmbienceLocal: typeof createLocalEngine;
@@ -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 / setVolume / toggleMute /
19
- * play (play only when the fallback election defers to a live leader).
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 { currentId: s.currentId, playing: s.playing && !s.leaderGone, volume: s.volume };
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;
@@ -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, stopped" once a scape has been selected but is not
85
- * playing, "Ambience: off" before anything was selected.
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(currentLabel: string | null, playing: boolean): string {
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(playing ? (sharedNow ?? nowPlayingLine(track)) : '');
324
+ const trackLine = $derived(
325
+ playing || paused ? (sharedNow ?? nowPlayingLine(track)) : ''
326
+ );
280
327
  const liveLabel = $derived(
281
- playError ? PLAY_ERROR_LABEL : ambienceLiveLabel(currentLabel, playing)
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 the shared engine.
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, stopped" once a scape has been selected but is not
65
- * playing, "Ambience: off" before anything was selected.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",