@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.
@@ -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, 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,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(playing ? nowPlayingLine(track) : '');
324
+ const trackLine = $derived(
325
+ playing || paused ? (sharedNow ?? nowPlayingLine(track)) : ''
326
+ );
275
327
  const liveLabel = $derived(
276
- playError ? PLAY_ERROR_LABEL : ambienceLiveLabel(currentLabel, playing)
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 current scape is a playlist → follow its track for the attribution line.
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, 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
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.58.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",