@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
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, and a shuffle / prev / play-pause / next transport — the Shuffle toggle is an `aria-pressed` IconButton, persisted per station and defaulting to the scape's registered `shuffle` option; a session's order is FRESHLY shuffled — never the engine's scape-id-seeded rand, which made every session identical — with recently-heard tracks pushed to the end of the new order (no-repeat across sessions; per-station recents in localStorage `jatui:ambience:recents:<id>`, guarded, capped `RECENTS_CAP` 200 by track url; the window resets once the whole station has been heard) — `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, streamUrl?})` registers an OUTBOUND LINK item (new tab, `rel="noopener noreferrer"`, external glyph, http(s) only) — streams are never relayed. With a public `streamUrl` (absolute http(s) via `safeAmbienceHref`; a bad scheme is refused and degrades to the link) the station becomes a PLAYABLE scape (`scapes/radio.ts`, `isRadioScape`): the browser fetches the stream client-side through a plain `<audio>` element — deliberately outside the Web Audio graph (a MediaElementSourceNode without CORS headers is silenced by the tainted-graph rule, and station streams rarely send CORS) — with loudness mirroring the engine master via the optional `ScapeContext.volume` hook (`el.volume = gain × master`, refreshed each lookahead tick, so volume + cross-tab mute still apply); the now-playing row shows the station name, the `Open station site` attribution link and an error line when the stream stalls; play/pause only. `createPlaylistScape({ …, group? })` (default `'music'`); a playing playlist shows title — composer · license and exposes `state().position/duration/shuffle` + `skip()`/`prev()`/`setShuffle()`. `engine.list()` = playable scapes, `engine.items()` = scapes + links. Shared mode: remotes render the leader's published now-playing parts (`trackTitle/trackPosition/trackDuration/trackDownload/trackShuffle`) and route pause/resume/skip/prev/shuffle via commands. Helpers: `groupAmbienceItems`, `isAmbienceLink`, `isPlaylistScape`, `isRadioScape`, `trackAttribution`, `isTrackDownloadable`, `trackLicenseAllowsRedistribution`, `trackDownloadFilename`, `safeSharedTrackDownload`, `safeAmbienceHref`. Demo: `/components/ambience-toggle`.
|
|
340
340
|
|
|
341
341
|
### Sound (first-class sibling of motion)
|
|
342
342
|
|
package/dist/ambience/README.md
CHANGED
|
@@ -49,7 +49,8 @@ inline `tracks` or a `manifestUrl`:
|
|
|
49
49
|
| `manifestUrl` | Fetched **once, lazily, on the first play** — never at import. A failed fetch is not cached; the next play retries it. |
|
|
50
50
|
| `tracks` | Inline track list — no fetch at all (resolved eagerly, a pure local filter). |
|
|
51
51
|
| `crossfade` | Track-to-track crossfade in seconds, 0–30 (default 3). |
|
|
52
|
-
| `shuffle` | Fisher–Yates play order
|
|
52
|
+
| `shuffle` | Fisher–Yates play order (default off). `setShuffle()` toggles live: the current track stays, the rest reorder. The order is FRESH per session — a crypto-seeded source (`freshShuffleRand()`), never the engine's scape-id-seeded rand, which produced the same "shuffled" order every session (jatui-plbzq). Recently-heard tracks close the new order (no-repeat across sessions, see below). The toggle state persists per station (`jatui:ambience:shuffle:<id>`) and the scape's registered `shuffle` stays the default until the listener toggles. |
|
|
53
|
+
| `shuffleRand` | Injectable shuffle source for tests (default: a fresh `freshShuffleRand()` per play). |
|
|
53
54
|
| `gain` | Initial gain of the scape mix into the master chain, 0–1 (default 1). `setParam('gain', v)` ramps it live. |
|
|
54
55
|
| `fetchFn`, `createMediaElement` | Injectable for tests (default `fetch` / `new Audio(url)`). |
|
|
55
56
|
|
|
@@ -91,6 +92,17 @@ serving the audio must send CORS headers** (`Access-Control-Allow-Origin`);
|
|
|
91
92
|
a non-CORS source then fails loudly per track (the scape skips to the next
|
|
92
93
|
track) instead of playing silence into a tainted graph.
|
|
93
94
|
|
|
95
|
+
### No-repeat across sessions (jatui-plbzq)
|
|
96
|
+
|
|
97
|
+
Every track that starts sounding is remembered per station: localStorage
|
|
98
|
+
`jatui:ambience:recents:<scapeId>` (guarded try/catch, capped at
|
|
99
|
+
`RECENTS_CAP` = 200, ids are the tracks' urls — so the memory is shared
|
|
100
|
+
across tabs of the same origin). A shuffled order leads with unheard
|
|
101
|
+
tracks and closes with the recently-heard ones, so a returning listener
|
|
102
|
+
does not hear the same first tracks; when the WHOLE station has been
|
|
103
|
+
heard the window resets and a plain fresh shuffle plays. The rule applies
|
|
104
|
+
at play start, on cycle wrap and on a mid-play `setShuffle(true)`.
|
|
105
|
+
|
|
94
106
|
### Behavior notes
|
|
95
107
|
|
|
96
108
|
- Two ping-pong slots (element + source node + gain each) are **built once per
|
|
@@ -125,10 +137,42 @@ scape.subscribe((s) => {
|
|
|
125
137
|
});
|
|
126
138
|
scape.skip(); // crossfade to the next track now
|
|
127
139
|
scape.prev(); // restart the track when >3 s are in, else the previous track
|
|
128
|
-
scape.setShuffle(true);
|
|
140
|
+
scape.setShuffle(true); // persisted per station; state().shuffle reports it
|
|
129
141
|
scape.tracks(); // resolved list, or null until the manifest resolved
|
|
130
142
|
```
|
|
131
143
|
|
|
144
|
+
## Radio stream scape — a station's public stream in the player (jatui-plbzq)
|
|
145
|
+
|
|
146
|
+
`src/lib/ambience/scapes/radio.ts` — `radioStation({ id, label, href,
|
|
147
|
+
streamUrl })` builds an outbound link as before when `streamUrl` is
|
|
148
|
+
absent; with one, it builds a PLAYABLE scape: the station's public
|
|
149
|
+
stream, fetched by the BROWSER from the station (never relayed, never
|
|
150
|
+
proxied through the host).
|
|
151
|
+
|
|
152
|
+
- The stream url must be an absolute http(s) one (`safeAmbienceHref`) —
|
|
153
|
+
a bad scheme is refused with a console warning and the station degrades
|
|
154
|
+
to its link behavior. The station-site `href` stays the attribution
|
|
155
|
+
target (validated again when the row renders).
|
|
156
|
+
- The element is a plain `<audio>`, deliberately OUTSIDE the Web Audio
|
|
157
|
+
graph: a `MediaElementAudioSourceNode` without CORS headers is silenced
|
|
158
|
+
by the tainted-graph rule, and station streams rarely send
|
|
159
|
+
`Access-Control-Allow-Origin` (a `crossOrigin='anonymous'` load would
|
|
160
|
+
fail outright). Loudness mirrors the engine through the optional
|
|
161
|
+
`ScapeContext.volume` hook: `el.volume = scape gain × master volume`,
|
|
162
|
+
refreshed on the lookahead tick — so the volume slider and cross-tab
|
|
163
|
+
mute apply. A stop fades the element volume in steps, wall-clock.
|
|
164
|
+
- The scape's `state()`/`subscribe()` surface (`radio: true`) reports
|
|
165
|
+
`status`, `station`, `href` and an `error` line for a stream that
|
|
166
|
+
stalls or dies. The toggle renders the station name, an
|
|
167
|
+
`Open station site` link and play/pause only.
|
|
168
|
+
- `createRadioStreamScape()` is the low-level factory (throws on a bad
|
|
169
|
+
`streamUrl`); `radioStation()` is the public shape that picks link vs
|
|
170
|
+
scape.
|
|
171
|
+
|
|
172
|
+
**The host is responsible for a station's terms**: only add a
|
|
173
|
+
`streamUrl` for stations that publish their stream and allow third-party
|
|
174
|
+
players — jatui never relays audio and never claims the right to play.
|
|
175
|
+
|
|
132
176
|
Cast a scape from `ambience.list()` to `PlaylistScape` in the toggle to reach
|
|
133
177
|
it. The toggle renders the attribution line — `composer · performer · license`
|
|
134
178
|
— linked to `sourceUrl` while a playlist scape is playing, and (jatui-153tz) a
|
package/dist/ambience/engine.js
CHANGED
|
@@ -182,7 +182,10 @@ export function createAmbience(options = {}) {
|
|
|
182
182
|
lookahead: scheduleLookaheadFor(instCbs),
|
|
183
183
|
// Fresh PRNG per build: a shared stream would interleave between the
|
|
184
184
|
// outgoing and incoming scape during a crossfade.
|
|
185
|
-
rand: makeRand(id)
|
|
185
|
+
rand: makeRand(id),
|
|
186
|
+
// The live master volume — a scape that plays OUTSIDE the graph (a
|
|
187
|
+
// radio stream's plain element) mirrors it onto el.volume (jatui-plbzq).
|
|
188
|
+
volume: () => volume
|
|
186
189
|
};
|
|
187
190
|
let inst;
|
|
188
191
|
try {
|
|
@@ -29,17 +29,13 @@ export declare function ambienceGroupLabel(group: ScapeGroup, defs?: readonly Am
|
|
|
29
29
|
* groups appended in first-seen order.
|
|
30
30
|
*/
|
|
31
31
|
export declare function groupAmbienceItems<T extends AmbienceItem>(items: readonly T[], groups?: ReadonlyArray<ScapeGroup | AmbienceGroupDef>): AmbienceSection<T>[];
|
|
32
|
-
/** Build a radio-station link item (group 'radio' unless the host says otherwise). */
|
|
33
|
-
export declare function radioStation(options: {
|
|
34
|
-
id: string;
|
|
35
|
-
label: string;
|
|
36
|
-
href: string;
|
|
37
|
-
description?: string;
|
|
38
|
-
group?: ScapeGroup;
|
|
39
|
-
}): AmbienceLink;
|
|
40
32
|
/**
|
|
41
33
|
* The href the toggle may open: absolute http(s) only. A `javascript:` or
|
|
42
34
|
* `data:` href from a host's station list would run in the page, so it is
|
|
43
35
|
* refused (null) and the item renders disabled.
|
|
36
|
+
*
|
|
37
|
+
* `radioStation()` (the station-item factory, optionally WITH a playable
|
|
38
|
+
* `streamUrl`) lives in `./scapes/radio` — it needs the stream scape, and
|
|
39
|
+
* this module must stay import-free of the scapes.
|
|
44
40
|
*/
|
|
45
41
|
export declare function safeAmbienceHref(href: string | null | undefined): string | null;
|
package/dist/ambience/groups.js
CHANGED
|
@@ -69,21 +69,14 @@ export function groupAmbienceItems(items, groups) {
|
|
|
69
69
|
}
|
|
70
70
|
return out;
|
|
71
71
|
}
|
|
72
|
-
/** Build a radio-station link item (group 'radio' unless the host says otherwise). */
|
|
73
|
-
export function radioStation(options) {
|
|
74
|
-
return {
|
|
75
|
-
kind: 'link',
|
|
76
|
-
id: options.id,
|
|
77
|
-
label: options.label,
|
|
78
|
-
group: options.group ?? 'radio',
|
|
79
|
-
href: options.href,
|
|
80
|
-
description: options.description
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
72
|
/**
|
|
84
73
|
* The href the toggle may open: absolute http(s) only. A `javascript:` or
|
|
85
74
|
* `data:` href from a host's station list would run in the page, so it is
|
|
86
75
|
* refused (null) and the item renders disabled.
|
|
76
|
+
*
|
|
77
|
+
* `radioStation()` (the station-item factory, optionally WITH a playable
|
|
78
|
+
* `streamUrl`) lives in `./scapes/radio` — it needs the stream scape, and
|
|
79
|
+
* this module must stay import-free of the scapes.
|
|
87
80
|
*/
|
|
88
81
|
export function safeAmbienceHref(href) {
|
|
89
82
|
if (typeof href !== 'string')
|
package/dist/ambience/index.d.ts
CHANGED
|
@@ -11,17 +11,18 @@
|
|
|
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';
|
|
24
24
|
export { ScapeRegistry } from './registry';
|
|
25
|
-
export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups,
|
|
25
|
+
export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, safeAmbienceHref } from './groups';
|
|
26
26
|
export type { AmbienceSection } from './groups';
|
|
27
|
+
export { radioStation, createRadioStreamScape, isRadioScape, RADIO, type RadioStreamScape, type RadioStreamScapeOptions, type RadioStreamState, type RadioStreamStatus } from './scapes/radio';
|
|
27
28
|
export type { Ambience, AmbienceGroupDef, AmbienceItem, AmbienceLink, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './types';
|
package/dist/ambience/index.js
CHANGED
|
@@ -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,9 +25,14 @@ 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';
|
|
32
|
-
// Menu sections + outbound
|
|
33
|
-
export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups,
|
|
32
|
+
// Menu sections + the outbound-radio-link helpers (jatui-mdq2p).
|
|
33
|
+
export { AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, safeAmbienceHref } from './groups';
|
|
34
|
+
// Radio stream scape (jatui-plbzq) — a radioStation() with a public
|
|
35
|
+
// streamUrl plays the station's stream right in the player (client-side
|
|
36
|
+
// fetch from the station, never relayed); without one it is the outbound
|
|
37
|
+
// link it always was.
|
|
38
|
+
export { radioStation, createRadioStreamScape, isRadioScape, RADIO } from './scapes/radio';
|
|
@@ -37,6 +37,22 @@
|
|
|
37
37
|
* registry (jatui-poq30).
|
|
38
38
|
*/
|
|
39
39
|
import type { Scape, ScapeGroup } from '../types';
|
|
40
|
+
/** Cap on the recently-played list per station — at ~200 entries a station
|
|
41
|
+
* remembers the last 200 heard tracks; a longer station simply never hits
|
|
42
|
+
* the cap (the no-repeat window is then always "the last 200"). */
|
|
43
|
+
export declare const RECENTS_CAP = 200;
|
|
44
|
+
/**
|
|
45
|
+
* A FRESH random source for one session's shuffle order (jatui-plbzq):
|
|
46
|
+
* seeded once from crypto.getRandomValues, then streamed as mulberry32 —
|
|
47
|
+
* the same PRNG quality the engine uses, but never the same twice.
|
|
48
|
+
*
|
|
49
|
+
* The engine's per-scape rand is seeded by the SCAPE ID, so shuffling with
|
|
50
|
+
* it produced the identical order every session — the bug that started
|
|
51
|
+
* this. The seeded rand stays available for tests through
|
|
52
|
+
* `PlaylistScapeOptions.shuffleRand` (and the pure helpers below still
|
|
53
|
+
* take any `rand` injection); the default here is the fresh source.
|
|
54
|
+
*/
|
|
55
|
+
export declare function freshShuffleRand(): () => number;
|
|
40
56
|
export declare const PLAYLIST: Readonly<{
|
|
41
57
|
/** Crossfade between consecutive tracks (s). */
|
|
42
58
|
CROSSFADE: 3;
|
|
@@ -73,6 +89,11 @@ export interface PlaylistTrack {
|
|
|
73
89
|
license: string;
|
|
74
90
|
/** Page where the recording lives — the attribution link target. */
|
|
75
91
|
sourceUrl?: string;
|
|
92
|
+
/** Explicit download override (jatui-0vxcu): `true` offers the file even
|
|
93
|
+
* when the licence string carries NC/ND terms; `false` hides the download
|
|
94
|
+
* button even for a redistributable licence. Absent: the licence gate
|
|
95
|
+
* (`isTrackDownloadable`) decides from the `license` string. */
|
|
96
|
+
downloadable?: boolean;
|
|
76
97
|
}
|
|
77
98
|
export interface PlaylistManifest {
|
|
78
99
|
title?: string;
|
|
@@ -94,6 +115,11 @@ export interface PlaylistState {
|
|
|
94
115
|
/** Total seconds of the current track (0 until metadata reports one —
|
|
95
116
|
* a stream without a duration stays 0). */
|
|
96
117
|
duration: number;
|
|
118
|
+
/** Whether shuffled play is on right now (jatui-plbzq) — the transport's
|
|
119
|
+
* Shuffle toggle reads it and `setShuffle()` writes it. Persisted per
|
|
120
|
+
* station; the initial value is the stored preference, else the
|
|
121
|
+
* scape's `shuffle` option (stations register with `shuffle: true`). */
|
|
122
|
+
shuffle: boolean;
|
|
97
123
|
/** Present on error. */
|
|
98
124
|
error?: string;
|
|
99
125
|
}
|
|
@@ -111,9 +137,16 @@ export interface PlaylistScapeOptions {
|
|
|
111
137
|
tracks?: PlaylistTrack[];
|
|
112
138
|
/** Track crossfade (s), 0–30. Default 3. */
|
|
113
139
|
crossfade?: number;
|
|
114
|
-
/** Randomized play order (Fisher–Yates
|
|
115
|
-
*
|
|
140
|
+
/** Randomized play order (Fisher–Yates). Default false. Takes effect
|
|
141
|
+
* for the running order immediately. Since jatui-plbzq the order is
|
|
142
|
+
* FRESH per session — never the engine's seeded rand, which produced
|
|
143
|
+
* the same "shuffled" order every session — and recently-heard tracks
|
|
144
|
+
* are pushed to the end of the new order (no-repeat). */
|
|
116
145
|
shuffle?: boolean;
|
|
146
|
+
/** Injectable shuffle source for tests (default: a fresh unseeded
|
|
147
|
+
* `freshShuffleRand()` per play). Passed the same way the old seeded
|
|
148
|
+
* engine rand was, so order assertions stay deterministic. */
|
|
149
|
+
shuffleRand?: () => number;
|
|
117
150
|
/** Initial gain of the scape mix into the master chain, 0..1. Default 1. */
|
|
118
151
|
gain?: number;
|
|
119
152
|
/** Injectable for tests (default `fetch`). */
|
|
@@ -159,3 +192,29 @@ export declare function isPlaylistScape(scape: unknown): scape is PlaylistScape;
|
|
|
159
192
|
* (performer only when present and different from the composer).
|
|
160
193
|
*/
|
|
161
194
|
export declare function trackAttribution(track: PlaylistTrack | null | undefined): string;
|
|
195
|
+
/**
|
|
196
|
+
* Does this LICENCE STRING permit redistributing the file? The download
|
|
197
|
+
* allowlist: CC0, the public domain (incl. the Public Domain Mark, and "PD")
|
|
198
|
+
* and CC-BY / CC-BY-SA. Anything carrying NC (no commercial) or ND (no
|
|
199
|
+
* derivatives) terms is refused — the host's only way past that is an
|
|
200
|
+
* explicit `downloadable: true` on the track (`isTrackDownloadable`).
|
|
201
|
+
* Matching is on tokens, so "CC BY-SA 4.0", "cc-by-sa", "Public Domain" and
|
|
202
|
+
* "CC0 1.0" all pass while "CC BY-NC", "BY-NC-ND" and unknown strings
|
|
203
|
+
* (default-deny) do not.
|
|
204
|
+
*/
|
|
205
|
+
export declare function trackLicenseAllowsRedistribution(license: string | null | undefined): boolean;
|
|
206
|
+
/**
|
|
207
|
+
* May this track be downloaded? The licence gate behind the toggle's
|
|
208
|
+
* download button (jatui-0vxcu): an explicit boolean `downloadable` on the
|
|
209
|
+
* track overrides either way; otherwise the `license` string must permit
|
|
210
|
+
* redistribution (`trackLicenseAllowsRedistribution`). A track without a
|
|
211
|
+
* usable url is never downloadable.
|
|
212
|
+
*/
|
|
213
|
+
export declare function isTrackDownloadable(track: PlaylistTrack | null | undefined): boolean;
|
|
214
|
+
/**
|
|
215
|
+
* The download filename — attribution travels with the file:
|
|
216
|
+
* "<Composer> - <Title>.<ext>". The extension comes from the track url
|
|
217
|
+
* (`DEFAULT_AUDIO_EXT` when the url carries none). Both parts are
|
|
218
|
+
* sanitized for real filesystems and the result stays deterministic.
|
|
219
|
+
*/
|
|
220
|
+
export declare function trackDownloadFilename(track: PlaylistTrack | null | undefined): string;
|
|
@@ -1,3 +1,114 @@
|
|
|
1
|
+
// ---- shuffle randomness + no-repeat memory (jatui-plbzq) --------------------
|
|
2
|
+
/** Cap on the recently-played list per station — at ~200 entries a station
|
|
3
|
+
* remembers the last 200 heard tracks; a longer station simply never hits
|
|
4
|
+
* the cap (the no-repeat window is then always "the last 200"). */
|
|
5
|
+
export const RECENTS_CAP = 200;
|
|
6
|
+
function recentsKey(scapeId) {
|
|
7
|
+
return `jatui:ambience:recents:${scapeId}`;
|
|
8
|
+
}
|
|
9
|
+
function shufflePrefKey(scapeId) {
|
|
10
|
+
return `jatui:ambience:shuffle:${scapeId}`;
|
|
11
|
+
}
|
|
12
|
+
/** The recently-played track ids for a station, most recent first. Guarded
|
|
13
|
+
* try/catch (private mode / disabled storage / SSR → empty — the shuffle
|
|
14
|
+
* still works, it just repeats across sessions). */
|
|
15
|
+
function loadRecents(scapeId) {
|
|
16
|
+
try {
|
|
17
|
+
if (typeof localStorage === 'undefined')
|
|
18
|
+
return new Set();
|
|
19
|
+
const raw = localStorage.getItem(recentsKey(scapeId));
|
|
20
|
+
if (!raw)
|
|
21
|
+
return new Set();
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
if (!Array.isArray(parsed))
|
|
24
|
+
return new Set();
|
|
25
|
+
return new Set(parsed.filter((s) => typeof s === 'string'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return new Set();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function saveRecents(scapeId, ids) {
|
|
32
|
+
try {
|
|
33
|
+
if (typeof localStorage === 'undefined')
|
|
34
|
+
return;
|
|
35
|
+
localStorage.setItem(recentsKey(scapeId), JSON.stringify(ids.slice(0, RECENTS_CAP)));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* private mode / quota — persistence is best-effort */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** A track just started sounding: move its id to the front of the station's
|
|
42
|
+
* recents (dedup, capped). The station ids the track by its url — stable
|
|
43
|
+
* across sessions and, since recents live in localStorage, across tabs. */
|
|
44
|
+
function recordPlayed(scapeId, trackId) {
|
|
45
|
+
const list = [...loadRecents(scapeId)];
|
|
46
|
+
const at = list.indexOf(trackId);
|
|
47
|
+
if (at !== -1)
|
|
48
|
+
list.splice(at, 1);
|
|
49
|
+
list.unshift(trackId);
|
|
50
|
+
saveRecents(scapeId, list);
|
|
51
|
+
}
|
|
52
|
+
/** The persisted shuffle preference for a station (null = none saved). */
|
|
53
|
+
function loadShufflePref(scapeId) {
|
|
54
|
+
try {
|
|
55
|
+
if (typeof localStorage === 'undefined')
|
|
56
|
+
return null;
|
|
57
|
+
const raw = localStorage.getItem(shufflePrefKey(scapeId));
|
|
58
|
+
return raw === '1' ? true : raw === '0' ? false : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function saveShufflePref(scapeId, on) {
|
|
65
|
+
try {
|
|
66
|
+
if (typeof localStorage === 'undefined')
|
|
67
|
+
return;
|
|
68
|
+
localStorage.setItem(shufflePrefKey(scapeId), on ? '1' : '0');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* private mode / quota — persistence is best-effort */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A FRESH random source for one session's shuffle order (jatui-plbzq):
|
|
76
|
+
* seeded once from crypto.getRandomValues, then streamed as mulberry32 —
|
|
77
|
+
* the same PRNG quality the engine uses, but never the same twice.
|
|
78
|
+
*
|
|
79
|
+
* The engine's per-scape rand is seeded by the SCAPE ID, so shuffling with
|
|
80
|
+
* it produced the identical order every session — the bug that started
|
|
81
|
+
* this. The seeded rand stays available for tests through
|
|
82
|
+
* `PlaylistScapeOptions.shuffleRand` (and the pure helpers below still
|
|
83
|
+
* take any `rand` injection); the default here is the fresh source.
|
|
84
|
+
*/
|
|
85
|
+
export function freshShuffleRand() {
|
|
86
|
+
let s;
|
|
87
|
+
try {
|
|
88
|
+
const seed = new Uint32Array(1);
|
|
89
|
+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
90
|
+
crypto.getRandomValues(seed);
|
|
91
|
+
s = seed[0];
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
s = (Math.random() * 0x100000000) >>> 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
s = (Math.random() * 0x100000000) >>> 0;
|
|
99
|
+
}
|
|
100
|
+
return mulberry32(s || 1);
|
|
101
|
+
}
|
|
102
|
+
function mulberry32(seed) {
|
|
103
|
+
let s = seed >>> 0;
|
|
104
|
+
return () => {
|
|
105
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
106
|
+
let t = s;
|
|
107
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
108
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
109
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
1
112
|
export const PLAYLIST = Object.freeze({
|
|
2
113
|
/** Crossfade between consecutive tracks (s). */
|
|
3
114
|
CROSSFADE: 3,
|
|
@@ -29,7 +140,10 @@ export function createPlaylistScape(options) {
|
|
|
29
140
|
const crossfade = Math.min(30, Math.max(0, options.crossfade ?? PLAYLIST.CROSSFADE));
|
|
30
141
|
const mixGain0 = Math.min(1, Math.max(0, options.gain ?? 1));
|
|
31
142
|
const fetchFn = options.fetchFn ?? ((url) => fetch(url));
|
|
32
|
-
|
|
143
|
+
// Shuffle preference: the stored per-station choice wins, then the
|
|
144
|
+
// scape's registered default (stations pass shuffle: true — the task's
|
|
145
|
+
// "default ON for stations" is the host's registration, jatui-plbzq).
|
|
146
|
+
let shuffle = loadShufflePref(options.id) ?? (options.shuffle ?? false);
|
|
33
147
|
/** Resolved track list — cached across plays (the manifest is fetched once,
|
|
34
148
|
* on the first play). null = unresolved; a FAILED fetch stays null so the
|
|
35
149
|
* next play retries. Inline tracks resolve eagerly (a pure local filter —
|
|
@@ -46,7 +160,15 @@ export function createPlaylistScape(options) {
|
|
|
46
160
|
}
|
|
47
161
|
}
|
|
48
162
|
/** Shared UI snapshot + subscribers. Only the latest build writes it. */
|
|
49
|
-
let snap = {
|
|
163
|
+
let snap = {
|
|
164
|
+
status: 'idle',
|
|
165
|
+
track: null,
|
|
166
|
+
index: 0,
|
|
167
|
+
count: 0,
|
|
168
|
+
position: 0,
|
|
169
|
+
duration: 0,
|
|
170
|
+
shuffle
|
|
171
|
+
};
|
|
50
172
|
const subs = new Set();
|
|
51
173
|
/** Handle to the most recent build — skip()/setShuffle() route here. */
|
|
52
174
|
let latest = null;
|
|
@@ -132,6 +254,33 @@ export function createPlaylistScape(options) {
|
|
|
132
254
|
let order = [];
|
|
133
255
|
let pos = 0;
|
|
134
256
|
let failures = 0;
|
|
257
|
+
// This play's shuffle source: the injected one (tests) or a FRESH
|
|
258
|
+
// unseeded draw — never the engine's scape id-seeded rand, which made
|
|
259
|
+
// every session's "shuffled" order identical (jatui-plbzq root cause).
|
|
260
|
+
const shuffleRand = options.shuffleRand ?? freshShuffleRand();
|
|
261
|
+
/** Fresh shuffle order with the no-repeat rule (jatui-plbzq): tracks
|
|
262
|
+
* the listener has heard recently close the order, unheard ones lead;
|
|
263
|
+
* when the WHOLE station has been heard the recents reset and the
|
|
264
|
+
* order is a plain fresh shuffle. */
|
|
265
|
+
function recentsAwareOrder() {
|
|
266
|
+
const n = resolved?.length ?? 0;
|
|
267
|
+
const fresh = shuffledOrder(n, shuffleRand);
|
|
268
|
+
const heard = loadRecents(options.id);
|
|
269
|
+
if (heard.size === 0)
|
|
270
|
+
return fresh;
|
|
271
|
+
const tracks = resolved;
|
|
272
|
+
const unheard = [];
|
|
273
|
+
const recent = [];
|
|
274
|
+
for (const i of fresh)
|
|
275
|
+
(heard.has(tracks[i].url) ? recent : unheard).push(i);
|
|
276
|
+
if (unheard.length === 0) {
|
|
277
|
+
// The whole station has been heard — the no-repeat window starts
|
|
278
|
+
// over: clear it and play a plain fresh shuffle.
|
|
279
|
+
saveRecents(options.id, []);
|
|
280
|
+
return fresh;
|
|
281
|
+
}
|
|
282
|
+
return [...unheard, ...recent];
|
|
283
|
+
}
|
|
135
284
|
const isLive = () => instId === instanceSeq && !released;
|
|
136
285
|
function atTimer(ms, fn) {
|
|
137
286
|
if (released)
|
|
@@ -226,7 +375,7 @@ export function createPlaylistScape(options) {
|
|
|
226
375
|
fail(resolveError ?? 'Playlist has no playable tracks');
|
|
227
376
|
return;
|
|
228
377
|
}
|
|
229
|
-
order = shuffle ?
|
|
378
|
+
order = shuffle ? recentsAwareOrder() : plainOrder(resolved.length);
|
|
230
379
|
pos = 0;
|
|
231
380
|
startTrack(order[0], 'first');
|
|
232
381
|
}
|
|
@@ -292,6 +441,9 @@ export function createPlaylistScape(options) {
|
|
|
292
441
|
duration: 0,
|
|
293
442
|
error: undefined
|
|
294
443
|
});
|
|
444
|
+
// This track is now sounding: it goes on the station's no-repeat
|
|
445
|
+
// list (most recent first, capped — jatui-plbzq).
|
|
446
|
+
recordPlayed(options.id, track.url);
|
|
295
447
|
preloadNext();
|
|
296
448
|
}
|
|
297
449
|
function quietSlot(slot) {
|
|
@@ -335,17 +487,16 @@ export function createPlaylistScape(options) {
|
|
|
335
487
|
return -1;
|
|
336
488
|
return pos + 1 < order.length ? order[pos + 1] : order[0];
|
|
337
489
|
}
|
|
338
|
-
/** Order for the next cycle
|
|
339
|
-
* ended when a swap can avoid it. */
|
|
490
|
+
/** Order for the next cycle: no-repeat-aware when shuffled (never
|
|
491
|
+
* opens with the track that just ended when a swap can avoid it). */
|
|
340
492
|
function wrapOrder(lastPlayed) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
493
|
+
if (!shuffle)
|
|
494
|
+
return plainOrder(resolved?.length ?? 0);
|
|
495
|
+
const idx = recentsAwareOrder();
|
|
496
|
+
if (idx.length > 1 && idx[0] === lastPlayed) {
|
|
497
|
+
const t = idx[0];
|
|
498
|
+
idx[0] = idx[1];
|
|
499
|
+
idx[1] = t;
|
|
349
500
|
}
|
|
350
501
|
return idx;
|
|
351
502
|
}
|
|
@@ -549,8 +700,8 @@ export function createPlaylistScape(options) {
|
|
|
549
700
|
return;
|
|
550
701
|
const current = order[pos] ?? 0;
|
|
551
702
|
if (shuffle) {
|
|
552
|
-
|
|
553
|
-
|
|
703
|
+
// No-repeat-aware rest: recently-heard tracks close the order.
|
|
704
|
+
const rest = recentsAwareOrder().filter((i) => i !== current);
|
|
554
705
|
order = [current, ...rest];
|
|
555
706
|
pos = 0;
|
|
556
707
|
}
|
|
@@ -598,6 +749,10 @@ export function createPlaylistScape(options) {
|
|
|
598
749
|
},
|
|
599
750
|
setShuffle(on) {
|
|
600
751
|
shuffle = on;
|
|
752
|
+
// The per-station preference (jatui-plbzq): survives sessions and is
|
|
753
|
+
// read back as the initial value on the next create.
|
|
754
|
+
saveShufflePref(options.id, on);
|
|
755
|
+
setSnap({ shuffle: on });
|
|
601
756
|
if (latest && !latest.isReleased())
|
|
602
757
|
latest.reshuffle();
|
|
603
758
|
},
|
|
@@ -657,7 +812,8 @@ function isValidTrack(t) {
|
|
|
657
812
|
o.license.trim() !== '' &&
|
|
658
813
|
(o.performer === undefined || typeof o.performer === 'string') &&
|
|
659
814
|
(o.sourceUrl === undefined ||
|
|
660
|
-
(typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== ''))
|
|
815
|
+
(typeof o.sourceUrl === 'string' && o.sourceUrl.trim() !== '')) &&
|
|
816
|
+
(o.downloadable === undefined || typeof o.downloadable === 'boolean'));
|
|
661
817
|
}
|
|
662
818
|
/** Narrow a registered scape to a playlist (to reach its state/subscribe). */
|
|
663
819
|
export function isPlaylistScape(scape) {
|
|
@@ -679,3 +835,83 @@ export function trackAttribution(track) {
|
|
|
679
835
|
parts.push(track.license);
|
|
680
836
|
return parts.filter(Boolean).join(' · ');
|
|
681
837
|
}
|
|
838
|
+
// ---- download gate + filename (jatui-0vxcu) ---------------------------------
|
|
839
|
+
/**
|
|
840
|
+
* Does this LICENCE STRING permit redistributing the file? The download
|
|
841
|
+
* allowlist: CC0, the public domain (incl. the Public Domain Mark, and "PD")
|
|
842
|
+
* and CC-BY / CC-BY-SA. Anything carrying NC (no commercial) or ND (no
|
|
843
|
+
* derivatives) terms is refused — the host's only way past that is an
|
|
844
|
+
* explicit `downloadable: true` on the track (`isTrackDownloadable`).
|
|
845
|
+
* Matching is on tokens, so "CC BY-SA 4.0", "cc-by-sa", "Public Domain" and
|
|
846
|
+
* "CC0 1.0" all pass while "CC BY-NC", "BY-NC-ND" and unknown strings
|
|
847
|
+
* (default-deny) do not.
|
|
848
|
+
*/
|
|
849
|
+
export function trackLicenseAllowsRedistribution(license) {
|
|
850
|
+
if (typeof license !== 'string' || license.trim() === '')
|
|
851
|
+
return false;
|
|
852
|
+
const l = license.toLowerCase().replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
853
|
+
// NC / ND terms — as their own tokens ("by nc", "by-nc" → "by nc") or as
|
|
854
|
+
// the spelled-out words.
|
|
855
|
+
if (/(^|\s)(nc|nd|noncommercial|non commercial|noderivs|no derivs|no derivatives)(\s|$)/.test(l))
|
|
856
|
+
return false;
|
|
857
|
+
return /(^|\s)(cc0|cc by|cc by sa|public domain|pd|pdm)(\s|$)/.test(l);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* May this track be downloaded? The licence gate behind the toggle's
|
|
861
|
+
* download button (jatui-0vxcu): an explicit boolean `downloadable` on the
|
|
862
|
+
* track overrides either way; otherwise the `license` string must permit
|
|
863
|
+
* redistribution (`trackLicenseAllowsRedistribution`). A track without a
|
|
864
|
+
* usable url is never downloadable.
|
|
865
|
+
*/
|
|
866
|
+
export function isTrackDownloadable(track) {
|
|
867
|
+
if (!track || typeof track.url !== 'string' || track.url.trim() === '')
|
|
868
|
+
return false;
|
|
869
|
+
if (typeof track.downloadable === 'boolean')
|
|
870
|
+
return track.downloadable;
|
|
871
|
+
return trackLicenseAllowsRedistribution(track.license);
|
|
872
|
+
}
|
|
873
|
+
/** Cap on one filename part — a "Composer - Title.ext" name stays well under
|
|
874
|
+
* the 255-byte filename floor even with long attribution strings. */
|
|
875
|
+
const FILENAME_PART_MAX = 96;
|
|
876
|
+
/** Extension for a url with no readable one — the Musopen-style manifests
|
|
877
|
+
* this feature targets serve mp3. */
|
|
878
|
+
const DEFAULT_AUDIO_EXT = 'mp3';
|
|
879
|
+
/** One filename part: filesystem-hostile characters and control chars →
|
|
880
|
+
* spaces, runs collapsed, no leading/trailing dots or spaces (Windows
|
|
881
|
+
* rejects both), clamped to {@link FILENAME_PART_MAX}. */
|
|
882
|
+
function sanitizeFilenamePart(part) {
|
|
883
|
+
return part
|
|
884
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
885
|
+
.replace(/[\\/:*?"<>|]/g, ' ')
|
|
886
|
+
.replace(/\s+/g, ' ')
|
|
887
|
+
.trim()
|
|
888
|
+
.replace(/^[. ]+|[. ]+$/g, '')
|
|
889
|
+
.slice(0, FILENAME_PART_MAX)
|
|
890
|
+
.replace(/[. ]+$/g, ''); // the clamp can re-expose a trailing dot
|
|
891
|
+
}
|
|
892
|
+
/** The audio extension of `url`'s last path segment ('' when there is none
|
|
893
|
+
* or it is not 1–5 alphanumeric chars — query strings excluded). */
|
|
894
|
+
function urlAudioExt(url) {
|
|
895
|
+
if (typeof url !== 'string')
|
|
896
|
+
return '';
|
|
897
|
+
const path = url.split(/[?#]/)[0];
|
|
898
|
+
const file = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1);
|
|
899
|
+
const dot = file.lastIndexOf('.');
|
|
900
|
+
if (dot <= 0)
|
|
901
|
+
return '';
|
|
902
|
+
const ext = file.slice(dot + 1).toLowerCase();
|
|
903
|
+
return /^[a-z0-9]{1,5}$/.test(ext) ? ext : '';
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* The download filename — attribution travels with the file:
|
|
907
|
+
* "<Composer> - <Title>.<ext>". The extension comes from the track url
|
|
908
|
+
* (`DEFAULT_AUDIO_EXT` when the url carries none). Both parts are
|
|
909
|
+
* sanitized for real filesystems and the result stays deterministic.
|
|
910
|
+
*/
|
|
911
|
+
export function trackDownloadFilename(track) {
|
|
912
|
+
if (!track)
|
|
913
|
+
return '';
|
|
914
|
+
const composer = sanitizeFilenamePart(track.composer) || 'Unknown composer';
|
|
915
|
+
const title = sanitizeFilenamePart(track.title) || 'Untitled';
|
|
916
|
+
return `${composer} - ${title}.${urlAudioExt(track.url) || DEFAULT_AUDIO_EXT}`;
|
|
917
|
+
}
|