File size: 27,729 Bytes
568e7b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
/* Cadence standalone runtime β€” makes dashboard.js work with no backend at all.
   Two sources:
     β€’ demo   β†’ a built-in catalog + a virtual player, so every panel works offline
     β€’ server β†’ your own Cadence/Flask backend (any URL), for real search & audio
   Injected by build_standalone.py; the dashboard logic itself is unchanged. */
(() => {
  'use strict';

  /* Some browsers deny localStorage on file:// (opaque origin) and throw on mere access,
     which would take the whole app down. Fall back to memory so the file always runs. */
  (function ensureStorage() {
    let ok = true;
    try { window.localStorage.getItem('__probe__'); } catch { ok = false; }
    if (ok) return;
    const mem = new Map();
    const shim = {
      getItem: k => (mem.has(String(k)) ? mem.get(String(k)) : null),
      setItem: (k, v) => { mem.set(String(k), String(v)); },
      removeItem: k => { mem.delete(String(k)); },
      clear: () => mem.clear(),
      key: i => [...mem.keys()][i] ?? null,
      get length() { return mem.size; },
    };
    try { Object.defineProperty(window, 'localStorage', { value: shim, configurable: true }); }
    catch { try { window.localStorage = shim; } catch {} }
  })();

  const CFG_KEY = 'cadence:cfg';
  let CFG = { mode: 'demo', base: 'http://localhost:8000' };
  try { Object.assign(CFG, JSON.parse(localStorage.getItem(CFG_KEY) || '{}')); } catch {}
  const isDemo = () => CFG.mode !== 'server';

  /* ───────────────────────── deterministic helpers ───────────────────────── */
  const hash = s => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
  const pick = (arr, seed) => arr[hash(String(seed)) % arr.length];
  const range = (n, seed) => Array.from({ length: n }, (_, i) => i + (hash(String(seed)) % 7));

  /* artwork: inline SVG gradient β†’ works offline, from file://, no CORS */
  const artCache = new Map();
  function art(seed, label) {
    if (artCache.has(seed)) return artCache.get(seed);
    const h = hash(seed);
    const h1 = h % 360, h2 = (h1 + 40 + (h >> 8) % 90) % 360;
    const initials = (label || seed).split(/\s+/).slice(0, 2).map(w => w[0] || '').join('').toUpperCase();
    const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(${h1} 78% 58%)"/><stop offset="1" stop-color="hsl(${h2} 72% 34%)"/>
</linearGradient><radialGradient id="r" cx="0.25" cy="0.2" r="0.9">
<stop offset="0" stop-color="rgba(255,255,255,.5)"/><stop offset="1" stop-color="rgba(255,255,255,0)"/>
</radialGradient></defs>
<rect width="300" height="300" fill="url(#g)"/><rect width="300" height="300" fill="url(#r)"/>
<circle cx="${60 + h % 180}" cy="${70 + (h >> 5) % 160}" r="${40 + (h >> 9) % 55}" fill="rgba(255,255,255,.14)"/>
<text x="150" y="168" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="86"
 font-weight="700" fill="rgba(255,255,255,.92)" text-anchor="middle">${initials}</text></svg>`;
    const uri = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
    artCache.set(seed, uri);
    return uri;
  }

  /* ───────────────────────── the demo catalog ───────────────────────────── */
  const ARTISTS = [
    { name: 'Neon Vesper', genre: 'synthwave', blurb: 'Analog synths, midnight drives.', since: 2014 },
    { name: 'Marta Okonkwo', genre: 'neo-soul', blurb: 'Velvet vocals over warm Rhodes chords.', since: 2011 },
    { name: 'Glass Harbor', genre: 'indie rock', blurb: 'Reverb-soaked guitars from the coast.', since: 2016 },
    { name: 'Kaito Mori', genre: 'lofi', blurb: 'Tape hiss and rainy-window pianos.', since: 2018 },
    { name: 'The Paper Lanterns', genre: 'folk', blurb: 'Harmonies recorded in one room.', since: 2009 },
    { name: 'Solene', genre: 'dream pop', blurb: 'Wide-screen textures, whispered hooks.', since: 2017 },
    { name: 'Ravi Chandran Trio', genre: 'jazz', blurb: 'Modal jazz with modern edges.', since: 2005 },
    { name: 'Basement Choir', genre: 'post-punk', blurb: 'Angular riffs, chorused bass.', since: 2013 },
    { name: 'Aiko & the Static', genre: 'electropop', blurb: 'Glitched beats and bright toplines.', since: 2019 },
    { name: 'Elian Cruz', genre: 'afrobeat', blurb: 'Polyrhythms built for open air.', since: 2012 },
    { name: 'Mira Halloran', genre: 'piano', blurb: 'Solo piano, patient and cinematic.', since: 2008 },
    { name: 'Static Bloom', genre: 'shoegaze', blurb: 'Wall-of-sound guitars, soft focus.', since: 2015 },
  ];
  const GENRE_TITLES = ['synthwave', 'neo-soul', 'indie rock', 'lofi', 'folk', 'dream pop', 'jazz', 'post-punk', 'electropop', 'afrobeat', 'piano', 'shoegaze'];
  const TITLES = {
    'Neon Vesper': ['Midnight Arcade', 'Chrome Horizon', 'Laser Bloom', 'Afterglow Drive'],
    'Marta Okonkwo': ['Honey Static', 'Slow Bloom', 'Kitchen Table', 'Velvet Hours'],
    'Glass Harbor': ['Saltwater Radio', 'Ferry Lights', 'Low Tide Riot', 'Coastline Static'],
    'Kaito Mori': ['Rain on the Balcony', 'Paper Cups', 'Second Coffee', 'Window Seat'],
    'The Paper Lanterns': ['Winter Orchard', 'Hollow Pines', 'Long Way Home', 'Sunday Letters'],
    'Solene': ['Glasshouse', 'Half Light', 'Soft Machine', 'Marble Sky'],
    'Ravi Chandran Trio': ['Blue Interval', 'Snake Charmer', 'Modal Drift', 'Late Set'],
    'Basement Choir': ['Concrete Hymn', 'Sirens', 'Blackout Kids', 'Tape Loop'],
    'Aiko & the Static': ['Pixel Heart', 'Overdrive', 'Glitch Bloom', 'Night Bus'],
    'Elian Cruz': ['Open Air', 'Marimba Sunrise', 'Coast Road', 'Dance Until Dawn'],
    'Mira Halloran': ['Folded Light', 'Nocturne for Rain', 'Six Winters', 'Quiet Room'],
    'Static Bloom': ['Gauze', 'Slow Collapse', 'Hyacinth', 'Feedback Bloom'],
  };
  const ALBUMS = {
    'Neon Vesper': ['Chrome Horizon', 'Night Signals'],
    'Marta Okonkwo': ['Velvet Hours', 'Honey Static'],
    'Glass Harbor': ['Saltwater Radio', 'Low Tide'],
    'Kaito Mori': ['Window Seat', 'Second Coffee'],
    'The Paper Lanterns': ['Winter Orchard', 'Long Way Home'],
    'Solene': ['Glasshouse', 'Half Light'],
    'Ravi Chandran Trio': ['Late Set', 'Modal Drift'],
    'Basement Choir': ['Concrete Hymn', 'Blackout Kids'],
    'Aiko & the Static': ['Pixel Heart', 'Night Bus'],
    'Elian Cruz': ['Open Air', 'Marimba Sunrise'],
    'Mira Halloran': ['Folded Light', 'Quiet Room'],
    'Static Bloom': ['Gauze', 'Hyacinth'],
  };

  const artistId = name => 'AR' + hash(name).toString(36).slice(0, 14);
  const albumId = name => 'AL' + hash(name).toString(36).slice(0, 14);
  const ARTIST_BY_ID = new Map(ARTISTS.map(a => [artistId(a.name), a]));

  const CATALOG = [];
  ARTISTS.forEach((a, ai) => {
    (TITLES[a.name] || []).forEach((title, si) => {
      const alb = ALBUMS[a.name][si % ALBUMS[a.name].length];
      const vid = 'demo' + hash(a.name + title).toString(36).slice(0, 9);
      CATALOG.push({
        video_id: vid,
        title,
        artists: [{ name: a.name, id: artistId(a.name) }],
        artist_text: a.name,
        album: { name: alb, id: albumId(alb) },
        duration: 168 + (hash(vid) % 130),
        year: a.since + (si % 9),
        _genre: a.genre,
      });
    });
  });
  const CAT_BY_ID = new Map(CATALOG.map(t => [t.video_id, t]));
  const artistObj = name => {
    const a = ARTISTS.find(x => x.name === name) || ARTISTS[0];
    return { id: artistId(a.name), name: a.name, thumbnail: 'artist:' + a.name,
             subscribers: ((hash(a.name) % 900) / 100 + 0.4).toFixed(1) + 'M',
             monthly_listeners: 200000 + (hash(a.name) % 8000000) };
  };

  const REGIONS = ['ZZ', 'CH', 'US', 'GB', 'DE', 'FR', 'IT', 'ES', 'AT', 'NL', 'SE', 'NO', 'DK', 'FI', 'BE', 'PT', 'PL', 'CZ', 'IE', 'GR', 'JP', 'KR', 'IN', 'BR', 'MX', 'AR', 'CA', 'AU', 'NZ', 'ZA', 'NG', 'TR'];
  const REGION_NAMES = { ZZ: 'Global', CH: 'Switzerland', US: 'United States', GB: 'United Kingdom', DE: 'Germany', FR: 'France', IT: 'Italy', ES: 'Spain', AT: 'Austria', NL: 'Netherlands', SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', BE: 'Belgium', PT: 'Portugal', PL: 'Poland', CZ: 'Czechia', IE: 'Ireland', GR: 'Greece', JP: 'Japan', KR: 'South Korea', IN: 'India', BR: 'Brazil', MX: 'Mexico', AR: 'Argentina', CA: 'Canada', AU: 'Australia', NZ: 'New Zealand', ZA: 'South Africa', NG: 'Nigeria', TR: 'TΓΌrkiye' };

  const MOODS = ['Chill', 'Commute', 'Energize', 'Feel good', 'Focus', 'Romance', 'Sad hours', 'Sleep', 'Workout', 'Dinner party', 'Sunday morning'];
  const GENRES = ['Pop', 'Rock', 'Hip-Hop', 'Electronic', 'Jazz', 'Lo-fi', 'Indie', 'Metal', 'Classical', 'Soul', 'Funk', 'Folk', 'Latin', 'Ambient'];

  /* shuffle that is stable per seed so a page re-render doesn't reshuffle */
  const stableShuffle = (arr, seed) => arr.map(t => [hash(seed + t.video_id), t]).sort((a, b) => a[0] - b[0]).map(x => x[1]);
  const strip = t => ({ video_id: t.video_id, title: t.title, artists: t.artists, artist_text: t.artist_text, album: t.album, duration: t.duration, year: t.year });

  const playlistsFor = (key, seed) => {
    const names = [`${key} Essentials`, `${key} Deep Cuts`, `Late Night ${key}`, `${key} Rising`, `Pure ${key}`, `${key} for the Ride`, `${key} Classics`, `Fresh ${key}`];
    return names.map((title, i) => ({
      title, id: 'PL' + hash(key + title).toString(36).slice(0, 14),
      thumbnail: art('pl:' + key + title, title), count: 18 + (hash(title) % 40), _key: key, _i: i,
    }));
  };

  /* ───────────────────────── simulated engine telemetry ──────────────────── */
  const STARTED = Date.now();
  const STATS = { cached_bytes: 0, cached_tracks: 0, downloads: 0, download_failures: 0, secs: 0, downloads_inflight: 0 };
  const CACHED = new Map();   // video_id β†’ bytes, so the tile reports distinct tracks
  const CACHE_LIMIT = 3 * 1024 * 1024 * 1024;

  /* ───────────────────────── the demo "API" ──────────────────────────────── */
  function demoApi(path) {
    const [rawPath, qs] = path.split('?');
    const q = new URLSearchParams(qs || '');
    const p = rawPath.replace(/\/+$/, '');

    if (p === '/api/stats') {
      return { cached_tracks: STATS.cached_tracks, cached_bytes: STATS.cached_bytes, downloads: STATS.downloads,
        download_failures: STATS.download_failures, avg_download_seconds: STATS.downloads ? +(STATS.secs / STATS.downloads).toFixed(2) : null,
        downloads_inflight: STATS.downloads_inflight, bytes_served: STATS.cached_bytes,
        uptime_seconds: Math.round((Date.now() - STARTED) / 1000), cache_limit_bytes: CACHE_LIMIT };
    }
    if (p.startsWith('/api/ready/')) return { cached: true, bytes: 3_400_000 + (hash(p) % 900_000) };

    if (p === '/api/genres') {
      return { categories: [
        { title: 'Moods & moments', items: MOODS.map(m => ({ title: m, params: 'mood:' + m })) },
        { title: 'Genres', items: GENRES.map(g => ({ title: g, params: 'genre:' + g })) },
      ] };
    }
    if (p === '/api/genre') {
      const params = q.get('params') || '';
      const key = params.split(':').slice(1).join(':') || 'Chill';
      return { playlists: playlistsFor(key, params) };
    }
    if (p === '/api/playlist') {
      const id = q.get('id') || '';
      const limit = Math.min(Number(q.get('limit') || 80) || 80, 150);
      const pl = playlistsFor('Chill', 'x').find(x => x.id === id);
      const key = id.slice(2, 6) || 'chill';
      const tracks = stableShuffle(CATALOG, key).slice(0, limit).map(strip);
      return { title: pl ? pl.title : 'Playlist', author: 'Cadence demo library', track_count: tracks.length, tracks };
    }
    if (p === '/api/album') {
      const id = q.get('id') || '';
      const name = ARTISTS.map(a => ALBUMS[a.name]).flat().find(al => albumId(al) === id) || 'Chrome Horizon';
      const tracks = CATALOG.filter(t => t.album && t.album.name === name).map(strip);
      const year = tracks.length ? tracks[0].year : 2024;
      return { id, title: name, year, artist: tracks.length ? tracks[0].artist_text : '', tracks };
    }
    if (p === '/api/artist') {
      const name = q.get('name') || (ARTIST_BY_ID.get(q.get('id') || '') || {}).name || 'Neon Vesper';
      const a = ARTISTS.find(x => x.name === name) || ARTISTS[0];
      const top = CATALOG.filter(t => t.artist_text === a.name).map(strip);
      const similar = ARTISTS.filter(x => x.genre !== a.genre).slice(0, 6)
        .map(x => { const o = artistObj(x.name); return { id: o.id, name: o.name, thumbnail: o.thumbnail }; });
      const albums = ALBUMS[a.name].map(al => ({ id: albumId(al), title: al, type: 'Album',
        year: (CATALOG.find(t => t.album.name === al) || {}).year }));
      return { ...artistObj(a.name), description: a.blurb + ' Active since ' + a.since + '.',
        top_tracks: top, similar, albums };
    }
    if (p === '/api/artist/radio') {
      const name = (ARTIST_BY_ID.get(q.get('id') || '') || {}).name || 'Neon Vesper';
      const own = CATALOG.filter(t => t.artist_text === name);
      const others = stableShuffle(CATALOG.filter(t => t.artist_text !== name), name).slice(0, 24);
      return { station_name: name + ' Radio', tracks: [...own, ...others].map(strip) };
    }
    if (p === '/api/radio') {
      const seedId = q.get('videoId') || '';
      const seed = CAT_BY_ID.get(seedId) || CATALOG[0];
      const same = CATALOG.filter(t => t.artist_text === seed.artist_text && t.video_id !== seed.video_id);
      const vibe = stableShuffle(CATALOG.filter(t => t._genre === seed._genre && t.artist_text !== seed.artist_text), seedId);
      const rest = stableShuffle(CATALOG.filter(t => t._genre !== seed._genre), seedId);
      const tracks = [...same, ...vibe, ...rest].slice(0, 25).map(strip);
      return { seed: seed.title, tracks };
    }
    if (p === '/api/suggest') {
      const v = (q.get('q') || '').toLowerCase();
      const pool = [...new Set([...CATALOG.map(t => t.title), ...ARTISTS.map(a => a.name)])];
      return { suggestions: pool.filter(s => s.toLowerCase().includes(v)).slice(0, 8) };
    }
    if (p === '/api/trending') {
      const country = (q.get('country') || 'ZZ').toUpperCase();
      const limit = Math.min(Number(q.get('limit') || 24) || 24, 50);
      const tracks = stableShuffle(CATALOG, 'chart:' + country).slice(0, limit).map(strip);
      const artists = stableShuffle(ARTISTS, 'artists:' + country).slice(0, 20).map((a, i) => {
        const o = artistObj(a.name);
        const trend = hash(a.name + country) % 3;
        return { id: o.id, name: o.name, thumbnail: o.thumbnail, rank: i + 1, trend: trend === 0 ? 'up' : trend === 1 ? 'down' : null };
      });
      return { country, country_name: REGION_NAMES[country] || country, countries: REGIONS, tracks, artists };
    }
    if (p === '/api/search') {
      const term = (q.get('q') || '').toLowerCase().trim();
      const tokens = term.replace(/[-–—]/g, ' ').split(/\s+/).filter(t => t.length > 1);
      const fieldScore = (field, weight) => tokens.reduce((n, tok) => n + (field.includes(tok) ? weight : 0), 0);

      const scored = CATALOG.map(t => {
        const title = t.title.toLowerCase();
        const artist = t.artist_text.toLowerCase();
        const album = ((t.album && t.album.name) || '').toLowerCase();
        const genre = t._genre.toLowerCase();
        let sc = fieldScore(title, 6) + fieldScore(artist, 9) + fieldScore(album, 3) + fieldScore(genre, 4);
        if (term && artist === term) sc += 60;
        if (term && title === term) sc += 55;
        if (term && artist.includes(term)) sc += 25;
        if (term && title.includes(term)) sc += 20;
        // every word matching is much better than a partial match
        if (tokens.length > 1 && tokens.every(tok => (title + ' ' + artist + ' ' + genre).includes(tok))) sc += 18;
        return [sc, t];
      }).filter(x => x[0] > 0).sort((a, b) => b[0] - a[0]).map(x => x[1]);

      // artists: name/genre matches first, then whoever performs the matching songs
      const byName = ARTISTS.filter(a => term && (a.name.toLowerCase().includes(term) ||
        tokens.some(tok => a.name.toLowerCase().includes(tok) || a.genre.includes(tok))));
      const bySong = scored.map(t => ARTISTS.find(a => a.name === t.artist_text)).filter(Boolean);
      const artists = [...new Map([...byName, ...bySong].map(a => [a.name, a])).values()].slice(0, 8)
        .map(a => { const o = artistObj(a.name); return { id: o.id, name: o.name, thumbnail: o.thumbnail }; });

      // honest: no filler tracks when nothing matches
      return {
        tracks: scored.slice(0, 24).map(strip),
        artists,
        demo: true,
        suggestions: scored.length || artists.length ? [] :
          [...new Set([...ARTISTS.map(a => a.name), ...GENRES])].slice(0, 6),
      };
    }
    return {};
  }

  /* ───────────────────────── virtual player (demo mode) ──────────────────── */
  function makeVirtualAudio() {
    const listeners = new Map();
    let timer = null;
    const emit = (type, extra) => (listeners.get(type) || []).forEach(fn => fn(Object.assign({ type, target: el }, extra)));
    const el = {
      currentTime: 0, duration: 0, volume: 0.9, muted: false, paused: true, _src: '',
      addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(fn); },
      removeEventListener() {},
      dispatchEvent(e) { emit(e && e.type ? e.type : 'unknown'); return true; },
      play() {
        el.paused = false; emit('playing');
        clearInterval(timer);
        timer = setInterval(() => {
          if (el.paused) return;
          el.currentTime = Math.min(el.duration || 0, el.currentTime + 0.25);
          emit('timeupdate');
          if (el.duration && el.currentTime >= el.duration) { clearInterval(timer); el.paused = true; emit('ended'); }
        }, 250);
        return Promise.resolve();
      },
      pause() { el.paused = true; clearInterval(timer); emit('pause'); },
      load() {},
      get src() { return el._src; },
      set src(v) {
        el._src = v;
        const id = String(v).split('/').pop();
        const t = CAT_BY_ID.get(id);
        el.duration = t ? t.duration : 0;
        el.currentTime = 0;
        STATS.downloads += 1; STATS.secs += 0.4 + (hash(id) % 90) / 100;
        if (!CAT_BY_ID.has(id) || CACHED.has(id)) { STATS.downloads_inflight = 0; return; }
        const bytes = Math.round(el.duration * 16000);
        CACHED.set(id, bytes);
        STATS.cached_tracks = CACHED.size;
        STATS.cached_bytes = Math.min(CACHE_LIMIT, STATS.cached_bytes + bytes);
      },
    };
    return el;
  }

  /* ───────────────────────── source dispatcher ───────────────────────────── */
  async function serverFetch(path, opts) {
    let r;
    try { r = await fetch(CFG.base.replace(/\/$/, '') + path, opts); }
    catch (e) {
      const err = new Error('Can\'t reach the Cadence server at ' + CFG.base + ' β€” check the URL in βš™ Settings, or switch to Demo mode.');
      err.code = 'offline'; throw err;
    }
    let data = null; try { data = await r.json(); } catch {}
    if (!r.ok) {
      const err = new Error((data && data.error) || `Request failed (${r.status})`);
      err.status = r.status; err.code = data && data.code; err.hint = data && data.hint; throw err;
    }
    return data;
  }

  const SRC = {
    get mode() { return CFG.mode; },
    get base() { return CFG.base; },
    async api(path, opts) {
      if (isDemo()) return demoApi(path);
      return serverFetch(path, opts);
    },
    thumb(vid) { return isDemo() ? art('v:' + vid, (CAT_BY_ID.get(vid) || {}).title || vid) : CFG.base.replace(/\/$/, '') + '/api/thumb/' + vid; },
    img(u) {
      if (isDemo()) {
        const label = String(u).startsWith('artist:') ? String(u).slice(7) : 'Artist';
        return art('i:' + u, label);
      }
      return CFG.base.replace(/\/$/, '') + '/api/img?u=' + encodeURIComponent(u);
    },
    makeAudio(el) {
      if (isDemo()) return makeVirtualAudio();
      // the page may live on another origin, so /api/stream/... must be made absolute
      const base = CFG.base.replace(/\/$/, '');
      try {
        const proto = Object.getOwnPropertyDescriptor(window.HTMLMediaElement.prototype, 'src');
        Object.defineProperty(el, 'src', {
          configurable: true,
          get() { return proto.get.call(el); },
          set(v) { proto.set.call(el, String(v).startsWith('/api/') ? base + v : v); },
        });
      } catch {}
      return el;
    },
    async prefetch(id) { if (isDemo()) return; try { await fetch(CFG.base.replace(/\/$/, '') + '/api/prefetch/' + id, { method: 'POST' }); } catch {} },
    async diagnose(id) {
      if (isDemo()) return { kind: 'transient' };
      try {
        const r = await fetch(CFG.base.replace(/\/$/, '') + '/api/stream/' + id, { headers: { Range: 'bytes=0-1' } });
        if (r.ok) return { kind: 'transient' };
        let d = {}; try { d = await r.json(); } catch {}
        return { kind: d.code || 'unknown', message: d.error, hint: d.hint };
      } catch { return { kind: 'offline' }; }
    },
    saveCfg(next) {
      Object.assign(CFG, next);
      try { localStorage.setItem(CFG_KEY, JSON.stringify(CFG)); } catch {}
      location.reload();
    },
  };
  window.__SRC__ = SRC;
  window.__DEMO_CATALOG__ = CATALOG;

  /* ───────────────────────── settings dialog (injected) ──────────────────── */
  function mountUI() {
    const topbar = document.querySelector('.topbar');
    if (!topbar) return;
    const pill = document.createElement('button');
    pill.className = 'mode-pill ' + (isDemo() ? 'demo' : 'live');
    pill.id = 'modePill';
    pill.innerHTML = isDemo() ? '● demo' : '● connected';
    pill.title = isDemo() ? 'Demo data β€” click to connect a backend' : 'Live backend: ' + CFG.base;
    topbar.appendChild(pill);

    const wrap = document.createElement('div');
    wrap.className = 'cfg-overlay';
    wrap.id = 'cfgOverlay';
    wrap.hidden = true;
    wrap.innerHTML = `
      <div class="cfg-card" role="dialog" aria-modal="true" aria-label="Dashboard settings">
        <h2>Dashboard settings</h2>
        <p class="cfg-sub">This file runs on its own. Point it at a backend for real search and playback β€” or keep exploring the built-in demo library.</p>
        <label class="cfg-opt"><input type="radio" name="cfgmode" value="demo" ${isDemo() ? 'checked' : ''}>
          <span><b>Demo mode</b><em>Built-in catalog, simulated playback. Works offline, from a file, on any static host.</em></span></label>
        <label class="cfg-opt"><input type="radio" name="cfgmode" value="server" ${isDemo() ? '' : 'checked'}>
          <span><b>Connect to a Cadence backend</b><em>Real YouTube Music search, artist radios and audio streaming.</em></span></label>
        <input class="cfg-url" id="cfgUrl" type="url" placeholder="http://localhost:8000" value="${CFG.base.replace(/"/g, '&quot;')}">
        <div class="cfg-note" id="cfgNote">Run <code>./run.sh</code> in the Cadence project, then use <code>http://localhost:8000</code>.<br>
          If this page is served over <b>https</b>, the backend must be https too (browsers block mixed content).</div>
        <div class="cfg-actions">
          <button class="cfg-btn" id="cfgTest">Test connection</button>
          <button class="cfg-btn primary" id="cfgSave">Save &amp; reload</button>
        </div>
      </div>`;
    document.body.appendChild(wrap);

    const open = () => { wrap.hidden = false; };
    SRC.openSettings = open;
    pill.addEventListener('click', open);
    wrap.addEventListener('click', e => { if (e.target === wrap) wrap.hidden = true; });
    document.addEventListener('keydown', e => { if (e.key === 'Escape') wrap.hidden = true; });

    const note = wrap.querySelector('#cfgNote');
    wrap.querySelector('#cfgTest').addEventListener('click', async () => {
      const url = wrap.querySelector('#cfgUrl').value.trim().replace(/\/$/, '') || 'http://localhost:8000';
      note.className = 'cfg-note';
      note.textContent = 'Testing ' + url + ' …';
      try {
        const r = await fetch(url + '/api/stats', { cache: 'no-store' });
        const d = await r.json();
        note.className = 'cfg-note ok';
        note.textContent = `Connected βœ“ β€” ${d.cached_tracks} cached tracks, ${(d.cached_bytes / 1048576).toFixed(1)} MB ready.`;
      } catch {
        note.className = 'cfg-note err';
        note.textContent = 'No answer from ' + url + '. Is the backend running, and does it allow cross-origin requests?';
      }
    });
    wrap.querySelector('#cfgSave').addEventListener('click', () => {
      const mode = wrap.querySelector('input[name="cfgmode"]:checked').value;
      const base = wrap.querySelector('#cfgUrl').value.trim().replace(/\/$/, '') || 'http://localhost:8000';
      SRC.saveCfg({ mode, base });
    });
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountUI);
  else mountUI();

  /* ── PWA: install banner (Chrome/Edge/Samsung Internet, incl. Android) ── */
  const isStandalone = () =>
    window.matchMedia('(display-mode: standalone)').matches ||
    window.matchMedia('(display-mode: fullscreen)').matches ||
    window.navigator.standalone === true;

  let deferredPrompt = null;
  window.addEventListener('beforeinstallprompt', e => {
    e.preventDefault();
    deferredPrompt = e;
    if (isStandalone() || localStorage.getItem('cadence:install-dismissed')) return;
    showInstallBar();
  });

  window.addEventListener('appinstalled', () => {
    deferredPrompt = null;
    const bar = document.getElementById('installBar');
    if (bar) bar.remove();
    try { localStorage.setItem('cadence:install-dismissed', '1'); } catch {}
  });

  function showInstallBar() {
    if (document.getElementById('installBar') || isStandalone()) return;
    const bar = document.createElement('div');
    bar.className = 'install-bar';
    bar.id = 'installBar';
    bar.innerHTML = `
      <img src="icons/icon-192.png" alt="" width="40" height="40">
      <div class="ib-text"><b>Install Cadence</b><span>Add it to your home screen β€” opens full screen, works offline.</span></div>
      <button class="ib-go" id="ibInstall">Install</button>
      <button class="ib-x" id="ibDismiss" aria-label="Not now">βœ•</button>`;
    document.body.appendChild(bar);
    bar.querySelector('#ibInstall').addEventListener('click', async () => {
      if (!deferredPrompt) return;
      deferredPrompt.prompt();
      const { outcome } = await deferredPrompt.userChoice;
      deferredPrompt = null;
      if (outcome !== 'accepted') bar.remove();
    });
    bar.querySelector('#ibDismiss').addEventListener('click', () => {
      try { localStorage.setItem('cadence:install-dismissed', '1'); } catch {}
      bar.remove();
    });
  }

  // Android Chrome fires this when the criteria are met; keep the UI in sync if it retries
  setInterval(() => { if (deferredPrompt && !isStandalone()) showInstallBar(); }, 4000);
})();