@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.
- package/CLAUDE.md +1 -1
- package/dist/ambience/README.md +46 -2
- package/dist/ambience/engine.js +4 -1
- package/dist/ambience/groups.d.ts +4 -8
- package/dist/ambience/groups.js +4 -11
- package/dist/ambience/index.d.ts +4 -3
- package/dist/ambience/index.js +9 -4
- package/dist/ambience/scapes/playlist.d.ts +61 -2
- package/dist/ambience/scapes/playlist.js +252 -16
- package/dist/ambience/scapes/radio.d.ts +113 -0
- package/dist/ambience/scapes/radio.js +297 -0
- package/dist/ambience/shared.d.ts +41 -2
- package/dist/ambience/shared.js +56 -5
- package/dist/ambience/types.d.ts +7 -0
- package/dist/components/AmbienceToggle.svelte +362 -13
- package/dist/components/AmbienceToggle.svelte.d.ts +29 -7
- package/dist/index.d.ts +3 -2
- package/dist/index.js +5 -1
- package/package.json +1 -1
|
@@ -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 (
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
345
|
-
? (
|
|
346
|
-
:
|
|
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' &&
|
|
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
|
|
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 (
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
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';
|