@joewinke/jatui 0.61.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -336,7 +336,7 @@ const rec = createAudioRecorder({
336
336
 
337
337
  ### Ambience (subpath: `jatui/ambience`, components on the root)
338
338
 
339
- `createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = a now-playing row above volume (playing/paused only; playlists: track title — composer · license, elapsed/total progress bar, prev / play-pause / next transport — `prev()` restarts the track when >3 s are in, else goes back one; play-pause uses engine `pause()`/`resume()` which freeze the scape in place — generative scapes get play/pause only) + one row per section (Radio / Focus / Noise / Meditation … — host order via `groups: Array<ScapeGroup | {id, label}>`, default radio/focus/noise/meditation/nature/music, unlisted groups appended), each opening a submenu (→/Enter/Space open, ←/Esc close; ContextMenu submenu pattern). `radioStation({id, label, href})` registers an OUTBOUND LINK item (new tab, `rel="noopener noreferrer"`, external glyph, http(s) only) — streams are never relayed. `createPlaylistScape({ …, group? })` (default `'music'`); a playing playlist shows title — composer · license and exposes `state().position/duration` + `skip()`/`prev()`. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Shared mode: remotes render the leader's published now-playing parts (`trackTitle/trackPosition/trackDuration`) and route pause/resume/skip/prev via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
339
+ `createAmbience({ storageKey?, groups? })` / `createDefaultAmbience()` (headless generative Web Audio engine) + `AmbienceToggle` (app-shell speaker button). The toggle menu = a now-playing row above volume (playing/paused only; playlists: track title — composer · license, elapsed/total progress bar, prev / play-pause / next transport — `prev()` restarts the track when >3 s are in, else goes back one; play-pause uses engine `pause()`/`resume()` which freeze the scape in place — generative scapes get play/pause only; plus a Download IconButton for tracks whose licence permits redistribution — allowlist CC0/public-domain/CC-BY/CC-BY-SA via `isTrackDownloadable` (NC/ND refused unless the manifest flags `downloadable: true`); the url must survive `safeAmbienceHref` (absolute http/https — before the fetch AND the new-tab CORS-failure fallback; remotes re-validate the leader's publication via `safeSharedTrackDownload`); fetch→Blob→`a[download="Composer - Title.ext"]` (`trackDownloadFilename`); licence + source in the tooltip and the `aria-describedby` line; busy spinner never blocks playback) + one row per section (Radio / Focus / Noise / Meditation … — host order via `groups: Array<ScapeGroup | {id, label}>`, default radio/focus/noise/meditation/nature/music, unlisted groups appended), each opening a submenu (→/Enter/Space open, ←/Esc close; ContextMenu submenu pattern). `radioStation({id, label, href})` registers an OUTBOUND LINK item (new tab, `rel="noopener noreferrer"`, external glyph, http(s) only) — streams are never relayed. `createPlaylistScape({ …, group? })` (default `'music'`); a playing playlist shows title — composer · license and exposes `state().position/duration` + `skip()`/`prev()`. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Shared mode: remotes render the leader's published now-playing parts (`trackTitle/trackPosition/trackDuration/trackDownload`) and route pause/resume/skip/prev via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `trackAttribution`, `isTrackDownloadable`, `trackLicenseAllowsRedistribution`, `trackDownloadFilename`, `safeSharedTrackDownload`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
340
340
 
341
341
  ### Sound (first-class sibling of motion)
342
342
 
@@ -11,13 +11,13 @@
11
11
  */
12
12
  export { createAmbience, createAmbienceLocal } from './shared';
13
13
  export { TIMING } from './engine';
14
- export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, type SharedAmbience, type SharedAmbienceState, type SharedAmbienceOptions, type SharedChannel, type SharedLockManager } from './shared';
14
+ export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, safeSharedTrackDownload, type SharedAmbience, type SharedAmbienceState, type SharedAmbienceOptions, type SharedChannel, type SharedLockManager, type SharedTrackDownload } from './shared';
15
15
  export { createDefaultAmbience, DEFAULT_SCAPES, type DefaultAmbienceOptions } from './default';
16
16
  export { hibiscusScape } from './scapes/hibiscus';
17
17
  export { focusScape } from './scapes/focus';
18
18
  export { noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape } from './scapes/noise';
19
19
  export { rain, waves } from './scapes/nature';
20
- export { createPlaylistScape, isPlaylistScape, trackAttribution } from './scapes/playlist';
20
+ export { createPlaylistScape, isPlaylistScape, trackAttribution, trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './scapes/playlist';
21
21
  export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistState, PlaylistStatus, PlaylistTrack } from './scapes/playlist';
22
22
  export { BreathClock, BREATHS_PER_MINUTE } from './breath';
23
23
  export { MASTER } from './master';
@@ -13,7 +13,7 @@
13
13
  // the plain engine until a host passes { shared: true }.
14
14
  export { createAmbience, createAmbienceLocal } from './shared';
15
15
  export { TIMING } from './engine';
16
- export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING } from './shared';
16
+ export { createSharedAmbience, AMBIENCE_CHANNEL, AMBIENCE_LOCK, SHARED_TIMING, safeSharedTrackDownload } from './shared';
17
17
  export { createDefaultAmbience, DEFAULT_SCAPES } from './default';
18
18
  // The shipped scapes — hosts that want a hand-picked registry import them
19
19
  // individually instead of using createDefaultAmbience().
@@ -25,7 +25,7 @@ export { rain, waves } from './scapes/nature';
25
25
  // recordings. NOTE: never auto-registered in DEFAULT_SCAPES — a playlist is
26
26
  // per-host (its manifest URL and licensing claims are the host's); build one
27
27
  // with createPlaylistScape() and engine.register() it yourself.
28
- export { createPlaylistScape, isPlaylistScape, trackAttribution } from './scapes/playlist';
28
+ export { createPlaylistScape, isPlaylistScape, trackAttribution, trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './scapes/playlist';
29
29
  export { BreathClock, BREATHS_PER_MINUTE } from './breath';
30
30
  export { MASTER } from './master';
31
31
  export { ScapeRegistry } from './registry';
@@ -73,6 +73,11 @@ export interface PlaylistTrack {
73
73
  license: string;
74
74
  /** Page where the recording lives — the attribution link target. */
75
75
  sourceUrl?: string;
76
+ /** Explicit download override (jatui-0vxcu): `true` offers the file even
77
+ * when the licence string carries NC/ND terms; `false` hides the download
78
+ * button even for a redistributable licence. Absent: the licence gate
79
+ * (`isTrackDownloadable`) decides from the `license` string. */
80
+ downloadable?: boolean;
76
81
  }
77
82
  export interface PlaylistManifest {
78
83
  title?: string;
@@ -159,3 +164,29 @@ export declare function isPlaylistScape(scape: unknown): scape is PlaylistScape;
159
164
  * (performer only when present and different from the composer).
160
165
  */
161
166
  export declare function trackAttribution(track: PlaylistTrack | null | undefined): string;
167
+ /**
168
+ * Does this LICENCE STRING permit redistributing the file? The download
169
+ * allowlist: CC0, the public domain (incl. the Public Domain Mark, and "PD")
170
+ * and CC-BY / CC-BY-SA. Anything carrying NC (no commercial) or ND (no
171
+ * derivatives) terms is refused — the host's only way past that is an
172
+ * explicit `downloadable: true` on the track (`isTrackDownloadable`).
173
+ * Matching is on tokens, so "CC BY-SA 4.0", "cc-by-sa", "Public Domain" and
174
+ * "CC0 1.0" all pass while "CC BY-NC", "BY-NC-ND" and unknown strings
175
+ * (default-deny) do not.
176
+ */
177
+ export declare function trackLicenseAllowsRedistribution(license: string | null | undefined): boolean;
178
+ /**
179
+ * May this track be downloaded? The licence gate behind the toggle's
180
+ * download button (jatui-0vxcu): an explicit boolean `downloadable` on the
181
+ * track overrides either way; otherwise the `license` string must permit
182
+ * redistribution (`trackLicenseAllowsRedistribution`). A track without a
183
+ * usable url is never downloadable.
184
+ */
185
+ export declare function isTrackDownloadable(track: PlaylistTrack | null | undefined): boolean;
186
+ /**
187
+ * The download filename — attribution travels with the file:
188
+ * "<Composer> - <Title>.<ext>". The extension comes from the track url
189
+ * (`DEFAULT_AUDIO_EXT` when the url carries none). Both parts are
190
+ * sanitized for real filesystems and the result stays deterministic.
191
+ */
192
+ export declare function trackDownloadFilename(track: PlaylistTrack | null | undefined): string;
@@ -657,7 +657,8 @@ function isValidTrack(t) {
657
657
  o.license.trim() !== '' &&
658
658
  (o.performer === undefined || typeof o.performer === 'string') &&
659
659
  (o.sourceUrl === undefined ||
660
- (typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== '')));
660
+ (typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== '')) &&
661
+ (o.downloadable === undefined || typeof o.downloadable === 'boolean'));
661
662
  }
662
663
  /** Narrow a registered scape to a playlist (to reach its state/subscribe). */
663
664
  export function isPlaylistScape(scape) {
@@ -679,3 +680,83 @@ export function trackAttribution(track) {
679
680
  parts.push(track.license);
680
681
  return parts.filter(Boolean).join(' · ');
681
682
  }
683
+ // ---- download gate + filename (jatui-0vxcu) ---------------------------------
684
+ /**
685
+ * Does this LICENCE STRING permit redistributing the file? The download
686
+ * allowlist: CC0, the public domain (incl. the Public Domain Mark, and "PD")
687
+ * and CC-BY / CC-BY-SA. Anything carrying NC (no commercial) or ND (no
688
+ * derivatives) terms is refused — the host's only way past that is an
689
+ * explicit `downloadable: true` on the track (`isTrackDownloadable`).
690
+ * Matching is on tokens, so "CC BY-SA 4.0", "cc-by-sa", "Public Domain" and
691
+ * "CC0 1.0" all pass while "CC BY-NC", "BY-NC-ND" and unknown strings
692
+ * (default-deny) do not.
693
+ */
694
+ export function trackLicenseAllowsRedistribution(license) {
695
+ if (typeof license !== 'string' || license.trim() === '')
696
+ return false;
697
+ const l = license.toLowerCase().replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
698
+ // NC / ND terms — as their own tokens ("by nc", "by-nc" → "by nc") or as
699
+ // the spelled-out words.
700
+ if (/(^|\s)(nc|nd|noncommercial|non commercial|noderivs|no derivs|no derivatives)(\s|$)/.test(l))
701
+ return false;
702
+ return /(^|\s)(cc0|cc by|cc by sa|public domain|pd|pdm)(\s|$)/.test(l);
703
+ }
704
+ /**
705
+ * May this track be downloaded? The licence gate behind the toggle's
706
+ * download button (jatui-0vxcu): an explicit boolean `downloadable` on the
707
+ * track overrides either way; otherwise the `license` string must permit
708
+ * redistribution (`trackLicenseAllowsRedistribution`). A track without a
709
+ * usable url is never downloadable.
710
+ */
711
+ export function isTrackDownloadable(track) {
712
+ if (!track || typeof track.url !== 'string' || track.url.trim() === '')
713
+ return false;
714
+ if (typeof track.downloadable === 'boolean')
715
+ return track.downloadable;
716
+ return trackLicenseAllowsRedistribution(track.license);
717
+ }
718
+ /** Cap on one filename part — a "Composer - Title.ext" name stays well under
719
+ * the 255-byte filename floor even with long attribution strings. */
720
+ const FILENAME_PART_MAX = 96;
721
+ /** Extension for a url with no readable one — the Musopen-style manifests
722
+ * this feature targets serve mp3. */
723
+ const DEFAULT_AUDIO_EXT = 'mp3';
724
+ /** One filename part: filesystem-hostile characters and control chars →
725
+ * spaces, runs collapsed, no leading/trailing dots or spaces (Windows
726
+ * rejects both), clamped to {@link FILENAME_PART_MAX}. */
727
+ function sanitizeFilenamePart(part) {
728
+ return part
729
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
730
+ .replace(/[\\/:*?"<>|]/g, ' ')
731
+ .replace(/\s+/g, ' ')
732
+ .trim()
733
+ .replace(/^[. ]+|[. ]+$/g, '')
734
+ .slice(0, FILENAME_PART_MAX)
735
+ .replace(/[. ]+$/g, ''); // the clamp can re-expose a trailing dot
736
+ }
737
+ /** The audio extension of `url`'s last path segment ('' when there is none
738
+ * or it is not 1–5 alphanumeric chars — query strings excluded). */
739
+ function urlAudioExt(url) {
740
+ if (typeof url !== 'string')
741
+ return '';
742
+ const path = url.split(/[?#]/)[0];
743
+ const file = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1);
744
+ const dot = file.lastIndexOf('.');
745
+ if (dot <= 0)
746
+ return '';
747
+ const ext = file.slice(dot + 1).toLowerCase();
748
+ return /^[a-z0-9]{1,5}$/.test(ext) ? ext : '';
749
+ }
750
+ /**
751
+ * The download filename — attribution travels with the file:
752
+ * "<Composer> - <Title>.<ext>". The extension comes from the track url
753
+ * (`DEFAULT_AUDIO_EXT` when the url carries none). Both parts are
754
+ * sanitized for real filesystems and the result stays deterministic.
755
+ */
756
+ export function trackDownloadFilename(track) {
757
+ if (!track)
758
+ return '';
759
+ const composer = sanitizeFilenamePart(track.composer) || 'Unknown composer';
760
+ const title = sanitizeFilenamePart(track.title) || 'Untitled';
761
+ return `${composer} - ${title}.${urlAudioExt(track.url) || DEFAULT_AUDIO_EXT}`;
762
+ }
@@ -57,6 +57,31 @@ export declare const SHARED_TIMING: Readonly<{
57
57
  /** A remote play command waits this long (ms) for the leader's echo. */
58
58
  REMOTE_PLAY_TIMEOUT_MS: 2500;
59
59
  }>;
60
+ /** The current track's download info (jatui-0vxcu), published by the leader
61
+ * alongside the now-playing parts — present ONLY when the licence permits
62
+ * redistribution (`isTrackDownloadable`), so a remote tab can offer the
63
+ * same download button without re-deriving the gate or the filename. */
64
+ export interface SharedTrackDownload {
65
+ /** The track's audio url (the leader's manifest entry). */
66
+ url: string;
67
+ /** Ready-made filename — "Composer - Title.ext", built leader-side by
68
+ * `trackDownloadFilename` so attribution travels with the file. */
69
+ filename: string;
70
+ /** The licence string, for the button's tooltip and describedby line. */
71
+ license: string;
72
+ /** Page the recording came from (attribution link target). */
73
+ sourceUrl?: string;
74
+ }
75
+ /**
76
+ * A published download re-validated on the CONSUMING side (jatui-0vxcu):
77
+ * the leader is another tab, not a trusted source — its `url` must survive
78
+ * `safeAmbienceHref` (absolute http/https only: a `javascript:`/`data:` or
79
+ * scheme-relative url from a compromised manifest or a hostile tab would
80
+ * otherwise reach this tab's `window.open` fallback) and the filename and
81
+ * licence must be usable strings. Anything else returns null — the remote
82
+ * tab's download button hides. The validated url replaces the original.
83
+ */
84
+ export declare function safeSharedTrackDownload(download: SharedTrackDownload | null | undefined): SharedTrackDownload | null;
60
85
  /** The live cross-tab state every tab renders. */
61
86
  export interface SharedAmbienceState {
62
87
  /** Currently playing (or preselected) scape id, or null. */
@@ -92,6 +117,10 @@ export interface SharedAmbienceState {
92
117
  /** True while the leader is PAUSED (engine.pause() — the scape is frozen
93
118
  * in place, not torn down; resume continues it). */
94
119
  paused: boolean;
120
+ /** The published track's download info (url + filename + licence), or
121
+ * null when nothing is playing or the licence forbids redistribution
122
+ * (jatui-0vxcu). A remote tab's download button reads this. */
123
+ trackDownload: SharedTrackDownload | null;
95
124
  }
96
125
  /** An Ambience whose playback is shared across tabs (see module doc). */
97
126
  export interface SharedAmbience extends Ambience {
@@ -105,8 +134,10 @@ export interface SharedAmbience extends Ambience {
105
134
  /** Leader-side hook: the host publishes the playing track's attribution
106
135
  * (and playlist position + progress) so remote tabs show it too.
107
136
  * `position`/`duration`/`title` (jatui-153tz) feed the remote tabs'
108
- * now-playing row; nulls mean unknown. */
109
- setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null): void;
137
+ * now-playing row; nulls mean unknown. `download` (jatui-0vxcu) carries
138
+ * the track's download info when its licence permits redistribution —
139
+ * null clears it. */
140
+ setNowPlaying(line: string | null, trackIndex?: number | null, position?: number | null, duration?: number | null, title?: string | null, download?: SharedTrackDownload | null): void;
110
141
  /** Now-playing-row transport (jatui-153tz): skip / go back one track on a
111
142
  * playlist scape. A remote routes the command to the leader's instance. */
112
143
  skipTrack(id: string): void;
@@ -42,6 +42,7 @@
42
42
  */
43
43
  import { createAmbience as createLocalEngine } from './engine';
44
44
  import { isPlaylistScape } from './scapes/playlist';
45
+ import { safeAmbienceHref } from './groups';
45
46
  import { loadPersisted, savePersisted } from './persist';
46
47
  /** BroadcastChannel name — the ambience control channel for the origin. */
47
48
  export const AMBIENCE_CHANNEL = 'jatui:ambience';
@@ -58,6 +59,27 @@ export const SHARED_TIMING = Object.freeze({
58
59
  /** A remote play command waits this long (ms) for the leader's echo. */
59
60
  REMOTE_PLAY_TIMEOUT_MS: 2500
60
61
  });
62
+ /**
63
+ * A published download re-validated on the CONSUMING side (jatui-0vxcu):
64
+ * the leader is another tab, not a trusted source — its `url` must survive
65
+ * `safeAmbienceHref` (absolute http/https only: a `javascript:`/`data:` or
66
+ * scheme-relative url from a compromised manifest or a hostile tab would
67
+ * otherwise reach this tab's `window.open` fallback) and the filename and
68
+ * licence must be usable strings. Anything else returns null — the remote
69
+ * tab's download button hides. The validated url replaces the original.
70
+ */
71
+ export function safeSharedTrackDownload(download) {
72
+ if (!download || typeof download !== 'object')
73
+ return null;
74
+ const url = safeAmbienceHref(download.url);
75
+ if (!url)
76
+ return null;
77
+ if (typeof download.filename !== 'string' || download.filename.trim() === '')
78
+ return null;
79
+ if (typeof download.license !== 'string' || download.license.trim() === '')
80
+ return null;
81
+ return { ...download, url };
82
+ }
61
83
  function randomTabId() {
62
84
  try {
63
85
  return `tab-${crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
@@ -120,6 +142,8 @@ export function createSharedAmbience(options = {}) {
120
142
  let nowPlayingTitle = null;
121
143
  let nowPlayingPosition = null;
122
144
  let nowPlayingDuration = null;
145
+ /** The published track's download info (jatui-0vxcu). */
146
+ let nowPlayingDownload = null;
123
147
  const ambienceSubs = new Set();
124
148
  const sharedSubs = new Set();
125
149
  function sharedState() {
@@ -137,7 +161,8 @@ export function createSharedAmbience(options = {}) {
137
161
  trackIndex: role === 'leader' ? nowPlayingIndex : (remoteS?.trackIndex ?? null),
138
162
  trackTitle: role === 'leader' ? nowPlayingTitle : (remoteS?.trackTitle ?? null),
139
163
  trackPosition: role === 'leader' ? nowPlayingPosition : (remoteS?.trackPosition ?? null),
140
- trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null)
164
+ trackDuration: role === 'leader' ? nowPlayingDuration : (remoteS?.trackDuration ?? null),
165
+ trackDownload: role === 'leader' ? nowPlayingDownload : (remoteS?.trackDownload ?? null)
141
166
  };
142
167
  }
143
168
  function ambienceState() {
@@ -206,6 +231,7 @@ export function createSharedAmbience(options = {}) {
206
231
  nowPlayingTitle = null;
207
232
  nowPlayingPosition = null;
208
233
  nowPlayingDuration = null;
234
+ nowPlayingDownload = null;
209
235
  remoteS = null;
210
236
  if (heartbeatTimer) {
211
237
  clearInterval(heartbeatTimer);
@@ -404,7 +430,7 @@ export function createSharedAmbience(options = {}) {
404
430
  // The leader tab is closing: mark it gone at once — no staleness
405
431
  // wait, and never a surprise autoplay in a background tab.
406
432
  if (remoteS && (remoteS.leaderTabId === msg.from || lastBeatTab === msg.from)) {
407
- remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
433
+ remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
408
434
  leaderGone = true;
409
435
  emitShared();
410
436
  }
@@ -487,7 +513,7 @@ export function createSharedAmbience(options = {}) {
487
513
  if (remoteS?.playing &&
488
514
  lastBeatAt > 0 &&
489
515
  Date.now() - lastBeatAt > staleMs) {
490
- remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
516
+ remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
491
517
  leaderGone = true;
492
518
  emitShared();
493
519
  }
@@ -571,7 +597,7 @@ export function createSharedAmbience(options = {}) {
571
597
  post({ t: 'cmd', cmd: 'stop', from: tabId });
572
598
  // Optimistic: the leader echoes the authoritative state right after.
573
599
  if (remoteS)
574
- remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null };
600
+ remoteS = { ...remoteS, playing: false, paused: false, nowPlaying: null, trackDownload: null };
575
601
  emitShared();
576
602
  }
577
603
  /** Freeze in place (the scape is not torn down; a playlist keeps its track
@@ -697,12 +723,13 @@ export function createSharedAmbience(options = {}) {
697
723
  setVolume,
698
724
  setMuted,
699
725
  toggleMute,
700
- setNowPlaying(line, index, position, duration, title) {
726
+ setNowPlaying(line, index, position, duration, title, download) {
701
727
  nowPlaying = line;
702
728
  nowPlayingIndex = line ? (index ?? null) : null;
703
729
  nowPlayingPosition = line ? (position ?? null) : null;
704
730
  nowPlayingDuration = line ? (duration ?? null) : null;
705
731
  nowPlayingTitle = line ? (title ?? null) : null;
732
+ nowPlayingDownload = line ? (download ?? null) : null;
706
733
  if (role === 'leader') {
707
734
  publish();
708
735
  emitShared();
@@ -32,6 +32,16 @@
32
32
  * focus) get the play/pause control only. The transport joins the
33
33
  * menu's roving order; a paused row stays visible (its play button is
34
34
  * the resume) and a stopped one hides.
35
+ * - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
36
+ * public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
37
+ * marks the track `downloadable: true`) also get a Download button
38
+ * next to the transport: the track url is fetched to a Blob (a plain
39
+ * cross-origin `<a download>` is ignored), saved under
40
+ * "Composer - Title.ext" so attribution travels with the file, and a
41
+ * CORS failure falls back to opening the url in a new tab. The busy
42
+ * spinner never blocks playback. Shared mode: a remote tab downloads
43
+ * the leader's published track url (`SharedAmbienceState.trackDownload`)
44
+ * — or hides the button when the leader published none.
35
45
  * - A playing playlist shows its current track's attribution (title,
36
46
  * composer, license) under the playlist item and its section row.
37
47
  * - With a menu or submenu open, the mouse wheel over that surface
@@ -233,8 +243,18 @@
233
243
  import { getMotionContext } from '../motion/level';
234
244
  import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
235
245
  import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
246
+ import {
247
+ isTrackDownloadable,
248
+ trackDownloadFilename
249
+ } from '../ambience/scapes/playlist';
250
+ import IconButton from './IconButton.svelte';
251
+ import { safeSharedTrackDownload } from '../ambience/shared';
252
+ import type {
253
+ SharedAmbience,
254
+ SharedAmbienceState,
255
+ SharedTrackDownload
256
+ } from '../ambience/shared';
236
257
  import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
237
- import type { SharedAmbience, SharedAmbienceState } from '../ambience/shared';
238
258
 
239
259
  let {
240
260
  engine,
@@ -252,6 +272,8 @@
252
272
  const menuId = $props.id();
253
273
  const hintId = `${menuId}-hint`;
254
274
  const subId = `${menuId}-sub`;
275
+ /** The download button's licence/source description line (jatui-0vxcu). */
276
+ const downloadHintId = `${menuId}-dl`;
255
277
 
256
278
  /** Long-press duration for the pointer path to the menu. */
257
279
  const LONG_PRESS_MS = 450;
@@ -309,6 +331,14 @@
309
331
  let sharedTitle = $state<string | null>(null);
310
332
  let sharedPosition = $state<number | null>(null);
311
333
  let sharedDuration = $state<number | null>(null);
334
+ /** The leader's published download info (jatui-0vxcu) — a remote tab's
335
+ * download button reads this; null hides the button (not published, or
336
+ * the licence forbids redistribution). */
337
+ let sharedDownload = $state<SharedTrackDownload | null>(null);
338
+ /** True while a download fetch is in flight — the button shows its busy
339
+ * spinner and refuses re-entry. The fetch runs beside playback; nothing
340
+ * here touches the engine. */
341
+ let downloadBusy = $state(false);
312
342
 
313
343
  // Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
314
344
  // the pending partial notch between wheel events so trackpad pixel deltas
@@ -360,6 +390,46 @@
360
390
  );
361
391
  const npPercent = $derived(progressPercent(npPosition, npDuration));
362
392
 
393
+ // --- download button (jatui-0vxcu) ---------------------------------------
394
+ /** What the download button offers right now: the playing (or paused)
395
+ * playlist track's url + ready-made filename + licence, gated by the
396
+ * licence AND by `safeAmbienceHref` — the url has to be an absolute
397
+ * http(s) one, because it is fetched and (on CORS failure) opened in a
398
+ * new tab; a `javascript:`/`data:`/scheme-relative entry means no button.
399
+ * On a remote tab the leader's publication is re-validated the same way
400
+ * (the leader is another tab, not a trusted source) — null → no button.
401
+ * Generative scapes never have one. */
402
+ const currentDownload = $derived.by(() => {
403
+ if (!showNowPlaying) return null;
404
+ if (isRemote) return safeSharedTrackDownload(sharedDownload);
405
+ const t = currentIsPlaylist ? (playlist?.track ?? null) : null;
406
+ if (!t || !isTrackDownloadable(t)) return null;
407
+ const url = safeAmbienceHref(t.url);
408
+ if (!url) return null;
409
+ return {
410
+ url,
411
+ filename: trackDownloadFilename(t),
412
+ license: t.license,
413
+ sourceUrl: t.sourceUrl
414
+ } satisfies SharedTrackDownload;
415
+ });
416
+ /** The button's tooltip: licence + source link — CC-BY needs attribution. */
417
+ const downloadTooltip = $derived(
418
+ currentDownload
419
+ ? `Download ${currentDownload.filename} · ${currentDownload.license}${
420
+ currentDownload.sourceUrl ? ` · source: ${currentDownload.sourceUrl}` : ''
421
+ }`
422
+ : ''
423
+ );
424
+ /** The sr-only line the button's aria-describedby points at. */
425
+ const downloadHint = $derived(
426
+ currentDownload
427
+ ? `Download as ${currentDownload.filename}. Licence: ${currentDownload.license}.${
428
+ currentDownload.sourceUrl ? ` Source: ${currentDownload.sourceUrl}.` : ''
429
+ }`
430
+ : ''
431
+ );
432
+
363
433
  const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
364
434
  const ICON_SIZE = $derived(compact ? 18 : 20);
365
435
 
@@ -399,6 +469,7 @@
399
469
  sharedTitle = s.trackTitle;
400
470
  sharedPosition = s.trackPosition;
401
471
  sharedDuration = s.trackDuration;
472
+ sharedDownload = s.trackDownload ?? null;
402
473
  });
403
474
  });
404
475
 
@@ -416,12 +487,25 @@
416
487
  return item.subscribe((s) => {
417
488
  playlist = s;
418
489
  track = s.status === 'playing' ? s.track : null;
490
+ // The download rides the same publication (jatui-0vxcu): only a track
491
+ // whose licence permits redistribution AND whose url survives
492
+ // safeAmbienceHref (absolute http/https) is offered to the other tabs.
493
+ const t = s.status === 'playing' ? s.track : null;
494
+ const dlUrl = t && isTrackDownloadable(t) ? safeAmbienceHref(t.url) : null;
419
495
  engine.setNowPlaying?.(
420
- s.status === 'playing' && s.track ? nowPlayingLine(s.track) : null,
496
+ s.status === 'playing' && t ? nowPlayingLine(t) : null,
421
497
  s.index,
422
498
  s.status === 'playing' ? s.position : null,
423
499
  s.status === 'playing' ? s.duration : null,
424
- s.track?.title ?? null
500
+ s.track?.title ?? null,
501
+ t && dlUrl
502
+ ? {
503
+ url: dlUrl,
504
+ filename: trackDownloadFilename(t),
505
+ license: t.license,
506
+ ...(t.sourceUrl ? { sourceUrl: t.sourceUrl } : {})
507
+ }
508
+ : null
425
509
  );
426
510
  });
427
511
  });
@@ -682,6 +766,47 @@
682
766
  if (isPlaylistScape(currentItem)) currentItem.skip();
683
767
  }
684
768
 
769
+ /**
770
+ * The download button (jatui-0vxcu). A cross-origin `<a download>` is
771
+ * ignored by browsers, so the file is fetched to a Blob and saved through
772
+ * an object URL — the attribution filename was built by the pure helper.
773
+ * A failed fetch (CORS) falls back to opening the url in a new tab. Both
774
+ * the fetch and the fallback go through `safeAmbienceHref` — a url that
775
+ * is not absolute http(s) (a hostile manifest, or a hostile shared-mode
776
+ * leader) never reaches `window.open`. The fetch runs beside the engine —
777
+ * playback is never blocked or touched.
778
+ */
779
+ async function onDownloadClick() {
780
+ const info = currentDownload;
781
+ const href = safeAmbienceHref(info?.url);
782
+ if (!info || !href || downloadBusy) return;
783
+ downloadBusy = true;
784
+ try {
785
+ const res = await fetch(href);
786
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
787
+ const blob = await res.blob();
788
+ const objectUrl = URL.createObjectURL(blob);
789
+ const a = document.createElement('a');
790
+ a.href = objectUrl;
791
+ a.download = info.filename;
792
+ document.body.appendChild(a);
793
+ a.click();
794
+ a.remove();
795
+ // The browser has the blob reference the moment it accepts the
796
+ // download; the revoke is belt-and-braces housekeeping.
797
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
798
+ } catch {
799
+ // CORS refused or the network failed — the url still opens.
800
+ try {
801
+ window.open(href, '_blank', 'noopener,noreferrer');
802
+ } catch {
803
+ /* a blocked popup is not worth crashing over */
804
+ }
805
+ } finally {
806
+ downloadBusy = false;
807
+ }
808
+ }
809
+
685
810
  // --- wheel volume over the open surfaces (jatui-xomny) -------------------
686
811
  // While the menu (or one of its submenus) is open the wheel adjusts volume
687
812
  // instead of scrolling the page — EXCEPT over a scrollable list that can
@@ -1064,7 +1189,67 @@
1064
1189
  </svg>
1065
1190
  </button>
1066
1191
  {/if}
1192
+ {#if currentDownload}
1193
+ <!-- Download (jatui-0vxcu): only for tracks whose licence
1194
+ permits redistribution (or the leader's published url
1195
+ on a remote tab). aria-describedby points at the
1196
+ licence line below — the tooltip's own action also
1197
+ rewrites describedby while it shows, so the line stays
1198
+ in the row as readable content either way. -->
1199
+ <IconButton
1200
+ label="Download {npTitle}"
1201
+ variant="ghost"
1202
+ size="sm"
1203
+ shape="circle"
1204
+ tooltip={downloadTooltip}
1205
+ tooltipPlacement="top"
1206
+ aria-describedby={downloadHintId}
1207
+ tabindex={-1}
1208
+ data-ambience-nav
1209
+ disabled={downloadBusy}
1210
+ onclick={onDownloadClick}
1211
+ >
1212
+ {#if downloadBusy}
1213
+ <!-- busy: the fetch is in flight (playback unaffected) -->
1214
+ <svg
1215
+ xmlns="http://www.w3.org/2000/svg"
1216
+ viewBox="0 0 24 24"
1217
+ fill="none"
1218
+ stroke="currentColor"
1219
+ stroke-width="2"
1220
+ stroke-linecap="round"
1221
+ class="jatui-dl-spin size-4"
1222
+ aria-hidden="true"
1223
+ >
1224
+ <circle cx="12" cy="12" r="9" opacity="0.25" />
1225
+ <path d="M21 12a9 9 0 0 0-9-9" />
1226
+ </svg>
1227
+ {:else}
1228
+ <!-- download, lucide geometry -->
1229
+ <svg
1230
+ xmlns="http://www.w3.org/2000/svg"
1231
+ viewBox="0 0 24 24"
1232
+ fill="none"
1233
+ stroke="currentColor"
1234
+ stroke-width="2"
1235
+ stroke-linecap="round"
1236
+ stroke-linejoin="round"
1237
+ class="size-4"
1238
+ aria-hidden="true"
1239
+ >
1240
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1241
+ <polyline points="7 10 12 15 17 10" />
1242
+ <line x1="12" y1="15" x2="12" y2="3" />
1243
+ </svg>
1244
+ {/if}
1245
+ </IconButton>
1246
+ {/if}
1067
1247
  </div>
1248
+ {#if currentDownload}
1249
+ <!-- The licence + source the download carries — CC-BY needs
1250
+ attribution, so the filename alone is not enough. -->
1251
+ <p id={downloadHintId} class="sr-only">{downloadHint}</p>
1252
+ {/if}
1068
1253
  </div>
1069
1254
  {/if}
1070
1255
 
@@ -1315,5 +1500,20 @@
1315
1500
  .now-progress-fill {
1316
1501
  transition: none;
1317
1502
  }
1503
+ .jatui-dl-spin {
1504
+ animation: none;
1505
+ }
1506
+ }
1507
+
1508
+ /* The download button's busy spinner — task feedback, not ambient motion,
1509
+ * but it still holds still under reduced motion. */
1510
+ .jatui-dl-spin {
1511
+ animation: jatui-dl-rotate 900ms linear infinite;
1512
+ }
1513
+
1514
+ @keyframes jatui-dl-rotate {
1515
+ to {
1516
+ transform: rotate(360deg);
1517
+ }
1318
1518
  }
1319
1519
  </style>
@@ -31,6 +31,16 @@
31
31
  * focus) get the play/pause control only. The transport joins the
32
32
  * menu's roving order; a paused row stays visible (its play button is
33
33
  * the resume) and a stopped one hides.
34
+ * - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
35
+ * public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
36
+ * marks the track `downloadable: true`) also get a Download button
37
+ * next to the transport: the track url is fetched to a Blob (a plain
38
+ * cross-origin `<a download>` is ignored), saved under
39
+ * "Composer - Title.ext" so attribution travels with the file, and a
40
+ * CORS failure falls back to opening the url in a new tab. The busy
41
+ * spinner never blocks playback. Shared mode: a remote tab downloads
42
+ * the leader's published track url (`SharedAmbienceState.trackDownload`)
43
+ * — or hides the button when the leader published none.
34
44
  * - A playing playlist shows its current track's attribution (title,
35
45
  * composer, license) under the playlist item and its section row.
36
46
  * - With a menu or submenu open, the mouse wheel over that surface
@@ -141,8 +151,8 @@ export declare function wheelVolumeSteps(acc: {
141
151
  * slider itself would produce for the same value.
142
152
  */
143
153
  export declare function volumeAfterSteps(volume: number, steps: number, stepPct?: number): number;
144
- import type { Ambience } from '../ambience/types';
145
154
  import type { SharedAmbience } from '../ambience/shared';
155
+ import type { Ambience } from '../ambience/types';
146
156
  type $$ComponentProps = {
147
157
  /** 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
158
  engine: Ambience | SharedAmbience;
package/dist/index.d.ts CHANGED
@@ -423,4 +423,5 @@ export type { SharedAmbience, SharedAmbienceState, SharedAmbienceOptions } from
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
@@ -469,3 +469,7 @@ export { createAmbience, createAmbienceLocal, createSharedAmbience, AMBIENCE_CHA
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.62.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",