@joewinke/jatui 0.61.0 → 0.63.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.
@@ -26,12 +26,28 @@
26
26
  * - A playing (or paused) scape shows a now-playing block above the
27
27
  * volume row (jatui-153tz): for a playlist — the track title, its
28
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.
29
+ * shuffle / prev / play-pause / next transport (shuffle is an
30
+ * aria-pressed IconButton, persisted per station and fresh-shuffled
31
+ * per session with no-repeat — jatui-plbzq; prev restarts the track
32
+ * when more than 3 s are in, else goes back one; play-pause freezes
33
+ * the station without closing the menu; next skips). Generative
34
+ * scapes (noise / focus) get the play/pause control only. A radio
35
+ * station registered with a public `streamUrl` (jatui-plbzq) gets the
36
+ * station name, a `Open station site` attribution link and
37
+ * play/pause — the stream is fetched by the browser from the station
38
+ * itself, never relayed. The transport joins the menu's roving order;
39
+ * a paused row stays visible (its play button is the resume) and a
40
+ * stopped one hides.
41
+ * - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
42
+ * public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
43
+ * marks the track `downloadable: true`) also get a Download button
44
+ * next to the transport: the track url is fetched to a Blob (a plain
45
+ * cross-origin `<a download>` is ignored), saved under
46
+ * "Composer - Title.ext" so attribution travels with the file, and a
47
+ * CORS failure falls back to opening the url in a new tab. The busy
48
+ * spinner never blocks playback. Shared mode: a remote tab downloads
49
+ * the leader's published track url (`SharedAmbienceState.trackDownload`)
50
+ * — or hides the button when the leader published none.
35
51
  * - A playing playlist shows its current track's attribution (title,
36
52
  * composer, license) under the playlist item and its section row.
37
53
  * - With a menu or submenu open, the mouse wheel over that surface
@@ -88,6 +104,14 @@
88
104
  return `${track.title} — ${trackAttribution(track)}`;
89
105
  }
90
106
 
107
+ /** The radio row's attribution href (jatui-plbzq): the scape's site url
108
+ * re-validated with `safeAmbienceHref` — the registry could be hostile
109
+ * on a remote tab too, and the leader is another tab, not a source of
110
+ * trust. Null hides the 'Open station site' link. */
111
+ export function radioSite(scape: RadioStreamScape | null | undefined): string | null {
112
+ return scape ? safeAmbienceHref(scape.href) : null;
113
+ }
114
+
91
115
  /** "3:07" — m:ss for the now-playing row's elapsed/total (jatui-153tz). */
92
116
  export function formatTrackTime(seconds: number | null | undefined): string {
93
117
  if (
@@ -233,8 +257,19 @@
233
257
  import { getMotionContext } from '../motion/level';
234
258
  import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
235
259
  import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
260
+ import { isRadioScape, type RadioStreamScape, type RadioStreamState } from '../ambience/scapes/radio';
261
+ import {
262
+ isTrackDownloadable,
263
+ trackDownloadFilename
264
+ } from '../ambience/scapes/playlist';
265
+ import IconButton from './IconButton.svelte';
266
+ import { safeSharedTrackDownload } from '../ambience/shared';
267
+ import type {
268
+ SharedAmbience,
269
+ SharedAmbienceState,
270
+ SharedTrackDownload
271
+ } from '../ambience/shared';
236
272
  import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
237
- import type { SharedAmbience, SharedAmbienceState } from '../ambience/shared';
238
273
 
239
274
  let {
240
275
  engine,
@@ -252,6 +287,8 @@
252
287
  const menuId = $props.id();
253
288
  const hintId = `${menuId}-hint`;
254
289
  const subId = `${menuId}-sub`;
290
+ /** The download button's licence/source description line (jatui-0vxcu). */
291
+ const downloadHintId = `${menuId}-dl`;
255
292
 
256
293
  /** Long-press duration for the pointer path to the menu. */
257
294
  const LONG_PRESS_MS = 450;
@@ -299,6 +336,10 @@
299
336
  * now-playing row's progress bar. Null on plain/remote tabs without a
300
337
  * local playing instance. */
301
338
  let playlist = $state<PlaylistState | null>(null);
339
+ /** The current radio scape's live snapshot (jatui-plbzq) — its error
340
+ * line (a station that stalls or dies) and status. Null on remote tabs
341
+ * without a local playing instance. */
342
+ let radioState = $state<RadioStreamState | null>(null);
302
343
  /** The shared player's now-playing line (jatui-lm1mf): published by the
303
344
  * leader tab, so a remote tab shows the same track attribution without
304
345
  * owning the playlist instance. Null on a plain engine. */
@@ -309,6 +350,17 @@
309
350
  let sharedTitle = $state<string | null>(null);
310
351
  let sharedPosition = $state<number | null>(null);
311
352
  let sharedDuration = $state<number | null>(null);
353
+ /** The leader's published download info (jatui-0vxcu) — a remote tab's
354
+ * download button reads this; null hides the button (not published, or
355
+ * the licence forbids redistribution). */
356
+ let sharedDownload = $state<SharedTrackDownload | null>(null);
357
+ /** The leader's published playlist shuffle state (jatui-plbzq) — a
358
+ * remote tab's Shuffle toggle reads this; null when unknown. */
359
+ let sharedShuffle = $state<boolean | null>(null);
360
+ /** True while a download fetch is in flight — the button shows its busy
361
+ * spinner and refuses re-entry. The fetch runs beside playback; nothing
362
+ * here touches the engine. */
363
+ let downloadBusy = $state(false);
312
364
 
313
365
  // Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
314
366
  // the pending partial notch between wheel events so trackpad pixel deltas
@@ -337,13 +389,31 @@
337
389
  * published state and routes transport commands to it. */
338
390
  const isRemote = $derived(sharedRole === 'remote');
339
391
  const currentIsPlaylist = $derived(isPlaylistScape(currentItem));
392
+ /** The current scape is a radio stream (jatui-plbzq): its now-playing
393
+ * row shows the station name + the 'Open station site' attribution
394
+ * link, and play/pause only — no tracks to skip, no download. */
395
+ const currentIsRadio = $derived(isRadioScape(currentItem));
396
+ /** The current radio scape, narrowed — carries the validated site url
397
+ * even on a remote tab (the registry is the same everywhere). */
398
+ const currentRadio = $derived(currentIsRadio ? (currentItem as unknown as RadioStreamScape) : null);
399
+ const radioSiteHref = $derived(radioSite(currentRadio));
400
+ /** The radio row's error line — leader/local only (a remote has no
401
+ * local instance to report through). */
402
+ const radioError = $derived(radioState?.status === 'error' ? (radioState.error ?? '') : '');
403
+ /** The Shuffle toggle's pressed state (jatui-plbzq): the playing
404
+ * playlist's own truth locally; the leader's published one remotely. */
405
+ const shufflePressed = $derived(
406
+ currentIsPlaylist ? (isRemote ? (sharedShuffle ?? playlist?.shuffle ?? false) : (playlist?.shuffle ?? false)) : false
407
+ );
340
408
  /** The row exists while something is sounding or paused — a paused row
341
409
  * must stay (its play button is how you resume); a stopped one hides. */
342
410
  const showNowPlaying = $derived((playing || paused) && !!currentItem);
343
411
  const npTitle = $derived(
344
- isRemote
345
- ? (sharedTitle ?? '')
346
- : (playlist?.track?.title ?? (currentIsPlaylist ? '' : (currentLabel ?? '')))
412
+ currentIsRadio
413
+ ? (currentRadio?.label ?? '')
414
+ : isRemote
415
+ ? (sharedTitle ?? '')
416
+ : (playlist?.track?.title ?? (currentIsPlaylist ? '' : (currentLabel ?? '')))
347
417
  );
348
418
  const npAttrib = $derived(
349
419
  isRemote
@@ -360,6 +430,46 @@
360
430
  );
361
431
  const npPercent = $derived(progressPercent(npPosition, npDuration));
362
432
 
433
+ // --- download button (jatui-0vxcu) ---------------------------------------
434
+ /** What the download button offers right now: the playing (or paused)
435
+ * playlist track's url + ready-made filename + licence, gated by the
436
+ * licence AND by `safeAmbienceHref` — the url has to be an absolute
437
+ * http(s) one, because it is fetched and (on CORS failure) opened in a
438
+ * new tab; a `javascript:`/`data:`/scheme-relative entry means no button.
439
+ * On a remote tab the leader's publication is re-validated the same way
440
+ * (the leader is another tab, not a trusted source) — null → no button.
441
+ * Generative scapes never have one. */
442
+ const currentDownload = $derived.by(() => {
443
+ if (!showNowPlaying) return null;
444
+ if (isRemote) return safeSharedTrackDownload(sharedDownload);
445
+ const t = currentIsPlaylist ? (playlist?.track ?? null) : null;
446
+ if (!t || !isTrackDownloadable(t)) return null;
447
+ const url = safeAmbienceHref(t.url);
448
+ if (!url) return null;
449
+ return {
450
+ url,
451
+ filename: trackDownloadFilename(t),
452
+ license: t.license,
453
+ sourceUrl: t.sourceUrl
454
+ } satisfies SharedTrackDownload;
455
+ });
456
+ /** The button's tooltip: licence + source link — CC-BY needs attribution. */
457
+ const downloadTooltip = $derived(
458
+ currentDownload
459
+ ? `Download ${currentDownload.filename} · ${currentDownload.license}${
460
+ currentDownload.sourceUrl ? ` · source: ${currentDownload.sourceUrl}` : ''
461
+ }`
462
+ : ''
463
+ );
464
+ /** The sr-only line the button's aria-describedby points at. */
465
+ const downloadHint = $derived(
466
+ currentDownload
467
+ ? `Download as ${currentDownload.filename}. Licence: ${currentDownload.license}.${
468
+ currentDownload.sourceUrl ? ` Source: ${currentDownload.sourceUrl}.` : ''
469
+ }`
470
+ : ''
471
+ );
472
+
363
473
  const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
364
474
  const ICON_SIZE = $derived(compact ? 18 : 20);
365
475
 
@@ -399,6 +509,8 @@
399
509
  sharedTitle = s.trackTitle;
400
510
  sharedPosition = s.trackPosition;
401
511
  sharedDuration = s.trackDuration;
512
+ sharedDownload = s.trackDownload ?? null;
513
+ sharedShuffle = s.trackShuffle;
402
514
  });
403
515
  });
404
516
 
@@ -416,16 +528,47 @@
416
528
  return item.subscribe((s) => {
417
529
  playlist = s;
418
530
  track = s.status === 'playing' ? s.track : null;
531
+ // The download rides the same publication (jatui-0vxcu): only a track
532
+ // whose licence permits redistribution AND whose url survives
533
+ // safeAmbienceHref (absolute http/https) is offered to the other tabs.
534
+ const t = s.status === 'playing' ? s.track : null;
535
+ const dlUrl = t && isTrackDownloadable(t) ? safeAmbienceHref(t.url) : null;
419
536
  engine.setNowPlaying?.(
420
- s.status === 'playing' && s.track ? nowPlayingLine(s.track) : null,
537
+ s.status === 'playing' && t ? nowPlayingLine(t) : null,
421
538
  s.index,
422
539
  s.status === 'playing' ? s.position : null,
423
540
  s.status === 'playing' ? s.duration : null,
424
- s.track?.title ?? null
541
+ s.track?.title ?? null,
542
+ t && dlUrl
543
+ ? {
544
+ url: dlUrl,
545
+ filename: trackDownloadFilename(t),
546
+ license: t.license,
547
+ ...(t.sourceUrl ? { sourceUrl: t.sourceUrl } : {})
548
+ }
549
+ : null,
550
+ // The Shuffle toggle's truth (jatui-plbzq): published so a remote
551
+ // tab renders the leader's shuffle state.
552
+ s.shuffle
425
553
  );
426
554
  });
427
555
  });
428
556
 
557
+ // The current scape is a radio stream (jatui-plbzq) → follow its state
558
+ // for the error line (a stream that stalls or dies). The row's station
559
+ // name and attribution link come from the registered item itself, so a
560
+ // remote tab needs no publication to render them.
561
+ $effect(() => {
562
+ const item = currentItem;
563
+ if (!isRadioScape(item)) {
564
+ radioState = null;
565
+ return;
566
+ }
567
+ return item.subscribe((s) => {
568
+ radioState = s;
569
+ });
570
+ });
571
+
429
572
  // Breathing glyph: follow the engine's shared breath clock while playing,
430
573
  // and only where ambient motion is allowed (getMotionContext().ambient is
431
574
  // false under prefers-reduced-motion AND under data-motion="calm"). The
@@ -682,6 +825,62 @@
682
825
  if (isPlaylistScape(currentItem)) currentItem.skip();
683
826
  }
684
827
 
828
+ /** The Shuffle toggle (jatui-plbzq): flip the playing playlist's
829
+ * shuffle state — the per-station preference persists, and a session's
830
+ * order is freshly shuffled with recently-heard tracks last. A remote
831
+ * routes the command to the leader (its instance owns the running
832
+ * order and the persisted preference). */
833
+ function onShuffleClick() {
834
+ const next = !shufflePressed;
835
+ const e = engine as SharedAmbience;
836
+ if (isRemote && typeof e.shuffleTrack === 'function' && currentId) {
837
+ e.shuffleTrack(currentId, next);
838
+ return;
839
+ }
840
+ if (isPlaylistScape(currentItem)) currentItem.setShuffle(next);
841
+ }
842
+
843
+ /**
844
+ * The download button (jatui-0vxcu). A cross-origin `<a download>` is
845
+ * ignored by browsers, so the file is fetched to a Blob and saved through
846
+ * an object URL — the attribution filename was built by the pure helper.
847
+ * A failed fetch (CORS) falls back to opening the url in a new tab. Both
848
+ * the fetch and the fallback go through `safeAmbienceHref` — a url that
849
+ * is not absolute http(s) (a hostile manifest, or a hostile shared-mode
850
+ * leader) never reaches `window.open`. The fetch runs beside the engine —
851
+ * playback is never blocked or touched.
852
+ */
853
+ async function onDownloadClick() {
854
+ const info = currentDownload;
855
+ const href = safeAmbienceHref(info?.url);
856
+ if (!info || !href || downloadBusy) return;
857
+ downloadBusy = true;
858
+ try {
859
+ const res = await fetch(href);
860
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
861
+ const blob = await res.blob();
862
+ const objectUrl = URL.createObjectURL(blob);
863
+ const a = document.createElement('a');
864
+ a.href = objectUrl;
865
+ a.download = info.filename;
866
+ document.body.appendChild(a);
867
+ a.click();
868
+ a.remove();
869
+ // The browser has the blob reference the moment it accepts the
870
+ // download; the revoke is belt-and-braces housekeeping.
871
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
872
+ } catch {
873
+ // CORS refused or the network failed — the url still opens.
874
+ try {
875
+ window.open(href, '_blank', 'noopener,noreferrer');
876
+ } catch {
877
+ /* a blocked popup is not worth crashing over */
878
+ }
879
+ } finally {
880
+ downloadBusy = false;
881
+ }
882
+ }
883
+
685
884
  // --- wheel volume over the open surfaces (jatui-xomny) -------------------
686
885
  // While the menu (or one of its submenus) is open the wheel adjusts volume
687
886
  // instead of scrolling the page — EXCEPT over a scrollable list that can
@@ -990,7 +1189,45 @@
990
1189
  {#if npTitle}
991
1190
  <p class="block truncate text-sm font-medium" title={npTitle}>{npTitle}</p>
992
1191
  {/if}
993
- {#if npAttrib}
1192
+ {#if currentIsRadio}
1193
+ <!-- Radio (jatui-plbzq): the station's own site is the
1194
+ attribution — a real link, kept even though the stream
1195
+ now plays right here (it is fetched by the browser from
1196
+ the station, never relayed). A stream that stalls or
1197
+ dies says so instead of playing silence. -->
1198
+ <div class="text-2xs mt-0.5 flex items-center gap-2">
1199
+ {#if radioSiteHref}
1200
+ <a
1201
+ href={radioSiteHref}
1202
+ target="_blank"
1203
+ rel="noopener noreferrer"
1204
+ tabindex="-1"
1205
+ class="inline-flex min-w-0 items-center gap-1 text-primary
1206
+ hover:underline focus-visible:underline"
1207
+ >
1208
+ <span class="truncate">Open station site</span>
1209
+ <svg
1210
+ xmlns="http://www.w3.org/2000/svg"
1211
+ viewBox="0 0 24 24"
1212
+ fill="none"
1213
+ stroke="currentColor"
1214
+ stroke-width="2"
1215
+ stroke-linecap="round"
1216
+ stroke-linejoin="round"
1217
+ class="size-3 shrink-0"
1218
+ aria-hidden="true"
1219
+ >
1220
+ <path d="M15 3h6v6" />
1221
+ <path d="M10 14 21 3" />
1222
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
1223
+ </svg>
1224
+ </a>
1225
+ {/if}
1226
+ {#if radioError}
1227
+ <span class="truncate text-error" title={radioError}>{radioError}</span>
1228
+ {/if}
1229
+ </div>
1230
+ {:else if npAttrib}
994
1231
  <p class="text-2xs text-base-content/55 block truncate" title={npAttrib}>{npAttrib}</p>
995
1232
  {/if}
996
1233
  {#if npDuration > 0}
@@ -1011,6 +1248,43 @@
1011
1248
  </div>
1012
1249
  {/if}
1013
1250
  <div class="mt-1.5 flex items-center justify-center gap-1">
1251
+ {#if currentIsPlaylist}
1252
+ <!-- Shuffle (jatui-plbzq): an aria-pressed toggle over the
1253
+ playlist's fresh-per-session order (recently-heard tracks
1254
+ last). Persisted per station; a remote routes the toggle
1255
+ to the leader. -->
1256
+ <IconButton
1257
+ label="Shuffle"
1258
+ variant="ghost"
1259
+ size="sm"
1260
+ shape="circle"
1261
+ tooltip="Shuffle"
1262
+ tooltipPlacement="top"
1263
+ pressed={shufflePressed}
1264
+ tabindex={-1}
1265
+ data-ambience-nav
1266
+ onclick={onShuffleClick}
1267
+ >
1268
+ <svg
1269
+ xmlns="http://www.w3.org/2000/svg"
1270
+ viewBox="0 0 24 24"
1271
+ fill="none"
1272
+ stroke="currentColor"
1273
+ stroke-width="2"
1274
+ stroke-linecap="round"
1275
+ stroke-linejoin="round"
1276
+ class="size-4 {shufflePressed ? 'text-primary' : ''}"
1277
+ aria-hidden="true"
1278
+ >
1279
+ <!-- shuffle, lucide geometry -->
1280
+ <path d="m18 14 4 4-4 4" />
1281
+ <path d="m18 2 4 4-4 4" />
1282
+ <path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22" />
1283
+ <path d="M2 6h1.972a4 4 0 0 1 3.6 2.2" />
1284
+ <path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45" />
1285
+ </svg>
1286
+ </IconButton>
1287
+ {/if}
1014
1288
  {#if currentIsPlaylist}
1015
1289
  <button
1016
1290
  type="button"
@@ -1064,7 +1338,67 @@
1064
1338
  </svg>
1065
1339
  </button>
1066
1340
  {/if}
1341
+ {#if currentDownload}
1342
+ <!-- Download (jatui-0vxcu): only for tracks whose licence
1343
+ permits redistribution (or the leader's published url
1344
+ on a remote tab). aria-describedby points at the
1345
+ licence line below — the tooltip's own action also
1346
+ rewrites describedby while it shows, so the line stays
1347
+ in the row as readable content either way. -->
1348
+ <IconButton
1349
+ label="Download {npTitle}"
1350
+ variant="ghost"
1351
+ size="sm"
1352
+ shape="circle"
1353
+ tooltip={downloadTooltip}
1354
+ tooltipPlacement="top"
1355
+ aria-describedby={downloadHintId}
1356
+ tabindex={-1}
1357
+ data-ambience-nav
1358
+ disabled={downloadBusy}
1359
+ onclick={onDownloadClick}
1360
+ >
1361
+ {#if downloadBusy}
1362
+ <!-- busy: the fetch is in flight (playback unaffected) -->
1363
+ <svg
1364
+ xmlns="http://www.w3.org/2000/svg"
1365
+ viewBox="0 0 24 24"
1366
+ fill="none"
1367
+ stroke="currentColor"
1368
+ stroke-width="2"
1369
+ stroke-linecap="round"
1370
+ class="jatui-dl-spin size-4"
1371
+ aria-hidden="true"
1372
+ >
1373
+ <circle cx="12" cy="12" r="9" opacity="0.25" />
1374
+ <path d="M21 12a9 9 0 0 0-9-9" />
1375
+ </svg>
1376
+ {:else}
1377
+ <!-- download, lucide geometry -->
1378
+ <svg
1379
+ xmlns="http://www.w3.org/2000/svg"
1380
+ viewBox="0 0 24 24"
1381
+ fill="none"
1382
+ stroke="currentColor"
1383
+ stroke-width="2"
1384
+ stroke-linecap="round"
1385
+ stroke-linejoin="round"
1386
+ class="size-4"
1387
+ aria-hidden="true"
1388
+ >
1389
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1390
+ <polyline points="7 10 12 15 17 10" />
1391
+ <line x1="12" y1="15" x2="12" y2="3" />
1392
+ </svg>
1393
+ {/if}
1394
+ </IconButton>
1395
+ {/if}
1067
1396
  </div>
1397
+ {#if currentDownload}
1398
+ <!-- The licence + source the download carries — CC-BY needs
1399
+ attribution, so the filename alone is not enough. -->
1400
+ <p id={downloadHintId} class="sr-only">{downloadHint}</p>
1401
+ {/if}
1068
1402
  </div>
1069
1403
  {/if}
1070
1404
 
@@ -1315,5 +1649,20 @@
1315
1649
  .now-progress-fill {
1316
1650
  transition: none;
1317
1651
  }
1652
+ .jatui-dl-spin {
1653
+ animation: none;
1654
+ }
1655
+ }
1656
+
1657
+ /* The download button's busy spinner — task feedback, not ambient motion,
1658
+ * but it still holds still under reduced motion. */
1659
+ .jatui-dl-spin {
1660
+ animation: jatui-dl-rotate 900ms linear infinite;
1661
+ }
1662
+
1663
+ @keyframes jatui-dl-rotate {
1664
+ to {
1665
+ transform: rotate(360deg);
1666
+ }
1318
1667
  }
1319
1668
  </style>
@@ -25,12 +25,28 @@
25
25
  * - A playing (or paused) scape shows a now-playing block above the
26
26
  * volume row (jatui-153tz): for a playlist — the track title, its
27
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.
28
+ * shuffle / prev / play-pause / next transport (shuffle is an
29
+ * aria-pressed IconButton, persisted per station and fresh-shuffled
30
+ * per session with no-repeat — jatui-plbzq; prev restarts the track
31
+ * when more than 3 s are in, else goes back one; play-pause freezes
32
+ * the station without closing the menu; next skips). Generative
33
+ * scapes (noise / focus) get the play/pause control only. A radio
34
+ * station registered with a public `streamUrl` (jatui-plbzq) gets the
35
+ * station name, a `Open station site` attribution link and
36
+ * play/pause — the stream is fetched by the browser from the station
37
+ * itself, never relayed. The transport joins the menu's roving order;
38
+ * a paused row stays visible (its play button is the resume) and a
39
+ * stopped one hides.
40
+ * - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
41
+ * public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
42
+ * marks the track `downloadable: true`) also get a Download button
43
+ * next to the transport: the track url is fetched to a Blob (a plain
44
+ * cross-origin `<a download>` is ignored), saved under
45
+ * "Composer - Title.ext" so attribution travels with the file, and a
46
+ * CORS failure falls back to opening the url in a new tab. The busy
47
+ * spinner never blocks playback. Shared mode: a remote tab downloads
48
+ * the leader's published track url (`SharedAmbienceState.trackDownload`)
49
+ * — or hides the button when the leader published none.
34
50
  * - A playing playlist shows its current track's attribution (title,
35
51
  * composer, license) under the playlist item and its section row.
36
52
  * - With a menu or submenu open, the mouse wheel over that surface
@@ -68,6 +84,11 @@ export type AmbienceScapeGroup = AmbienceSection;
68
84
  export declare function groupScapes(items: AmbienceItem[], groups?: ReadonlyArray<ScapeGroup | AmbienceGroupDef>): AmbienceScapeGroup[];
69
85
  /** "Gymnopédie No. 1 — Erik Satie · Public domain": a playing track's line. */
70
86
  export declare function nowPlayingLine(track: PlaylistTrack | null | undefined): string;
87
+ /** The radio row's attribution href (jatui-plbzq): the scape's site url
88
+ * re-validated with `safeAmbienceHref` — the registry could be hostile
89
+ * on a remote tab too, and the leader is another tab, not a source of
90
+ * trust. Null hides the 'Open station site' link. */
91
+ export declare function radioSite(scape: RadioStreamScape | null | undefined): string | null;
71
92
  /** "3:07" — m:ss for the now-playing row's elapsed/total (jatui-153tz). */
72
93
  export declare function formatTrackTime(seconds: number | null | undefined): string;
73
94
  /** The now-playing progress fill, 0–100, clamped; 0 without a duration. */
@@ -141,8 +162,9 @@ export declare function wheelVolumeSteps(acc: {
141
162
  * slider itself would produce for the same value.
142
163
  */
143
164
  export declare function volumeAfterSteps(volume: number, steps: number, stepPct?: number): number;
144
- import type { Ambience } from '../ambience/types';
165
+ import { type RadioStreamScape } from '../ambience/scapes/radio';
145
166
  import type { SharedAmbience } from '../ambience/shared';
167
+ import type { Ambience } from '../ambience/types';
146
168
  type $$ComponentProps = {
147
169
  /** 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
170
  engine: Ambience | SharedAmbience;
package/dist/index.d.ts CHANGED
@@ -417,10 +417,11 @@ export { default as ImageReveal } from './components/ImageReveal.svelte';
417
417
  export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize, imageRevealTone, IMAGE_REVEAL_SIZES, IMAGE_REVEAL_RADIUS } from './components/imageReveal';
418
418
  export type { ImageRevealPhase, ImageRevealShape, ImageRevealSizeName, ImageRevealToneName, ImageRevealState } from './components/imageReveal';
419
419
  export { default as AmbienceToggle, AMBIENCE_GROUP_ORDER } from './components/AmbienceToggle.svelte';
420
- 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';
421
- export type { DefaultAmbienceOptions, AmbienceSection } from './ambience';
420
+ 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, createRadioStreamScape, isRadioScape, safeAmbienceHref } from './ambience';
421
+ export type { DefaultAmbienceOptions, AmbienceSection, RadioStreamScape, RadioStreamState, RadioStreamScapeOptions, RadioStreamStatus } from './ambience';
422
422
  export type { SharedAmbience, SharedAmbienceState, SharedAmbienceOptions } from './ambience';
423
423
  export type { Ambience, AmbienceGroupDef, AmbienceItem, AmbienceLink, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './ambience';
424
424
  export { createPlaylistScape } from './ambience';
425
425
  export { isPlaylistScape, trackAttribution } from './ambience';
426
+ export { trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './ambience';
426
427
  export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistState, PlaylistStatus, PlaylistTrack } from './ambience';
package/dist/index.js CHANGED
@@ -464,8 +464,12 @@ export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize
464
464
  // navigation; createDefaultAmbience() pre-registers every shipped scape
465
465
  // (hibiscus, focus, brown/pink/white noise, rain, waves).
466
466
  export { default as AmbienceToggle, AMBIENCE_GROUP_ORDER } from './components/AmbienceToggle.svelte';
467
- 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';
467
+ 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, createRadioStreamScape, isRadioScape, safeAmbienceHref } from './ambience';
468
468
  // Playlist scape (phase 2) — per-host, never in DEFAULT_SCAPES: build with
469
469
  // createPlaylistScape() and register on the engine yourself.
470
470
  export { createPlaylistScape } from './ambience';
471
471
  export { isPlaylistScape, trackAttribution } from './ambience';
472
+ // Download gate + filename builder (jatui-0vxcu) — the licence allowlist
473
+ // (CC0 / public domain / CC-BY / CC-BY-SA), the per-track override and the
474
+ // "Composer - Title.ext" name attribution travels under.
475
+ export { trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './ambience';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.61.0",
3
+ "version": "0.63.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",