-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscript.js
More file actions
2436 lines (2243 loc) · 102 KB
/
Copy pathscript.js
File metadata and controls
2436 lines (2243 loc) · 102 KB
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
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
(() => {
'use strict';
const data = window.MIX_DATA || { playlists: [], active: null };
const els = {
scenes: document.getElementById('scenes'),
streaksHost: document.getElementById('streaks-host'),
stationLabel: document.getElementById('station-label'),
stationName: document.getElementById('station-name'),
stationSub: document.getElementById('station-sub'),
stationPrev: document.getElementById('station-prev'),
stationNext: document.getElementById('station-next'),
connectPrompt: document.getElementById('connect-prompt'),
card: document.getElementById('card'),
cardArt: document.getElementById('card-art'),
cardTitle: document.getElementById('card-title'),
cardArtist: document.getElementById('card-artist'),
cardProgress: document.getElementById('card-progress'),
ctrlPrev: document.getElementById('ctrl-prev'),
ctrlPlay: document.getElementById('ctrl-play'),
ctrlNext: document.getElementById('ctrl-next'),
hotcorner: document.getElementById('hotcorner'),
gear: document.getElementById('gear'),
artTint: document.getElementById('art-tint'),
// DJ Request (Cmd+K)
dj: document.getElementById('dj'),
djBackdrop: document.getElementById('dj-backdrop'),
djForm: document.getElementById('dj-form'),
djInput: document.getElementById('dj-input'),
djDice: document.getElementById('dj-dice'),
djRollHint: document.getElementById('dj-roll-hint'),
djStatus: document.getElementById('dj-status'),
overlay: document.getElementById('overlay'),
overlayClose: document.getElementById('overlay-close'),
overlayKicker: document.getElementById('overlay-kicker'),
overlayTitle: document.getElementById('overlay-title'),
overlayBody: document.getElementById('overlay-body'),
overlayActions: document.getElementById('overlay-actions'),
};
// ── State ──
const LS_KEY = 'mixgen.liked.v1';
const LS_SKIPPED = 'mixgen.skipped.v1';
const LS_KNOWN_ARTISTS = 'mixgen.knownArtists.v1';
const LS_SETTINGS = 'mixgen.settings.v1';
const LS_ONBOARDED = 'mixgen.onboarded.v1';
const LS_ONBOARDING_STEP = 'mixgen.onboarding.step';
const DEFAULT_SETTINGS = {
bpmLocked: false,
albumArtTint: true,
};
function loadSettings() {
try {
const s = JSON.parse(localStorage.getItem(LS_SETTINGS) || '{}');
return { ...DEFAULT_SETTINGS, ...s };
} catch (e) { return { ...DEFAULT_SETTINGS }; }
}
function saveSettings(s) {
localStorage.setItem(LS_SETTINGS, JSON.stringify(s));
}
let activeId = data.active || (data.playlists[0] && data.playlists[0].id);
let isPlaying = false;
let sdkPlayer = null; // Spotify Web Playback SDK instance
let sdkDeviceId = null; // our browser device id, registered with Spotify Connect
let sdkReady = false;
let liked = loadLocal(LS_KEY);
let skipped = loadLocal(LS_SKIPPED);
let lastSeenTrack = null;
let pollInterval = null;
let inflightAdds = new Set();
let currentTrack = null;
// Iframe-level progress (works without OAuth — drives the live timer/progress bar)
let iframePos = 0;
let iframeDur = 0;
let iframeTrackIdx = 0;
let lastIframePos = 0;
// ── Auto-skip state ──
const MAX_AUTO_SKIPS_PER_TRACK = 3; // safety: never spam-skip the same URI
const MAX_AUTO_SKIPS_IN_A_ROW = 10; // safety: if every track is disfavored, just play one
const autoSkipCounter = {}; // uri → times we've auto-skipped it
let autoSkipsInARow = 0;
let autoSkipInProgress = false;
// Station-switching gate: while a station change is in flight, the SDK
// keeps firing player_state_changed events for the PREVIOUS station's
// track until Spotify finishes loading the new context (200-800ms).
// Without this gate, the old track gets rendered under the new station's
// nameplate ("BAROQUE CHAMBERS playing Trance Wax").
let stationSwitching = false;
let switchingToUri = null;
// ── Chapter auto-regen state ──
// After N listen-throughs on a station, background-trigger MCP to create a
// fresh playlist with the same vibe. We DERIVE the counter from the
// persistent `liked` array (each entry has stationId) and a per-station
// baseline that we snapshot when a regen fires. Effective listens for a
// station = liked-records-for-that-station - baseline.
//
// Threshold is 25 — matches legacy ~30-track stations (regen at ~83%
// through) and ~20% of new 120-track stations.
const CHAPTER_REGEN_THRESHOLD = 25;
const LS_CHAPTER_BASELINES = 'mixgen.chapterBaselines.v1';
const stationRegenInProgress = {}; // stationId → bool
const stationsAwaitingSwap = {}; // stationId → new spotifyUri
let chapterBaselines = {}; // stationId → liked-count at last regen
try {
chapterBaselines = JSON.parse(localStorage.getItem(LS_CHAPTER_BASELINES) || '{}');
} catch (e) { chapterBaselines = {}; }
function persistChapterBaselines() {
localStorage.setItem(LS_CHAPTER_BASELINES, JSON.stringify(chapterBaselines));
scheduleStateSync();
}
function likedCountForStation(stationId) {
let n = 0;
for (const l of liked) if (l.stationId === stationId) n++;
return n;
}
function effectiveChapterCount(stationId) {
return likedCountForStation(stationId) - (chapterBaselines[stationId] || 0);
}
// Returns true if this track should be silently skipped because the user
// already rejected it (URI in skipped list, or artist in last 20 skips).
function shouldAutoSkip(track) {
if (!track || !track.uri) return false;
// Exact-URI skip
if (skipped.some((s) => s && s.uri === track.uri)) return true;
// Artist-level skip — only consider the LAST 20 skips so the set doesn't
// grow unboundedly
const recentSkips = skipped.slice(-20);
if (recentSkips.length === 0) return false;
const disfavored = new Set();
recentSkips.forEach((s) => {
String(s.artist || '').split(',').forEach((a) => disfavored.add(a.trim().toLowerCase()));
});
const artistNames = (track.artists || []).map((a) => (a.name || '').toLowerCase());
return artistNames.some((n) => disfavored.has(n));
}
// Station-switch guard — ignore Spotify polls that return the OLD track
// while the iframe is still loading the new playlist.
let preSwitchUri = null;
let postSwitchUntil = 0;
// Station playlist URI cache: stationId -> Set<trackUri>
const stationTrackCache = {};
function loadLocal(k) {
try { return JSON.parse(localStorage.getItem(k) || '[]'); } catch (e) { return []; }
}
function saveLiked() { localStorage.setItem(LS_KEY, JSON.stringify(liked)); scheduleStateSync(); }
function saveSkipped() { localStorage.setItem(LS_SKIPPED, JSON.stringify(skipped)); scheduleStateSync(); }
// ── Server-side persistence (backup to state.json) ──
// localStorage already persists across sessions/restarts on the same browser,
// but we ALSO mirror to disk so:
// - "Clear browsing data" doesn't nuke listening history
// - You can read the same history from a fresh browser on the same machine
// - It survives a JSON-corruption bug in localStorage
// Server file: state.json (gitignored). One-way push: browser → server.
// On boot, if localStorage is empty, hydrate FROM the server file.
let stateSyncTimer = null;
async function scheduleStateSync() {
if (stateSyncTimer) clearTimeout(stateSyncTimer);
stateSyncTimer = setTimeout(async () => {
try {
await fetch('/state/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
liked,
skipped,
knownArtists: [...loadKnownArtists()],
chapterBaselines,
}),
});
} catch (e) {
// Server might not be running, or browser tab might be closing.
// Either way: localStorage already has the data, so this is just a
// missed backup. Don't block anything on it.
console.warn('[state] sync failed (non-fatal)', e.message || e);
}
}, 1500);
}
async function hydrateStateFromServer() {
// Only hydrate when local is empty — server is a backup, not the truth.
if (liked.length > 0 || skipped.length > 0) return;
try {
const resp = await fetch('/state/sync');
if (!resp.ok) return;
const remote = await resp.json();
if (remote.liked && remote.liked.length > 0) {
liked = remote.liked;
localStorage.setItem(LS_KEY, JSON.stringify(liked));
}
if (remote.skipped && remote.skipped.length > 0) {
skipped = remote.skipped;
localStorage.setItem(LS_SKIPPED, JSON.stringify(skipped));
}
if (remote.knownArtists && remote.knownArtists.length > 0) {
saveKnownArtists(new Set(remote.knownArtists));
}
if (remote.chapterBaselines && typeof remote.chapterBaselines === 'object') {
// Merge baselines: take MAX so we never accidentally re-fire regen
// on a browser that's behind on baseline updates.
for (const stationId in remote.chapterBaselines) {
const remoteN = remote.chapterBaselines[stationId] || 0;
const localN = chapterBaselines[stationId] || 0;
chapterBaselines[stationId] = Math.max(remoteN, localN);
}
localStorage.setItem(LS_CHAPTER_BASELINES, JSON.stringify(chapterBaselines));
}
if (remote.syncedAt) {
console.log(`[state] hydrated from server backup (last sync: ${new Date(remote.syncedAt).toLocaleString()})`);
}
} catch (e) {
console.warn('[state] hydrate skipped', e.message || e);
}
}
function playlistIdFromUri(uri) {
if (!uri) return null;
return uri.split(':').pop();
}
async function ensureStationTracksLoaded(stationId) {
if (stationTrackCache[stationId]) return stationTrackCache[stationId];
const mix = findMix(stationId);
if (!mix || !mix.spotifyUri) return new Set();
const pid = playlistIdFromUri(mix.spotifyUri);
try {
const uris = await SpotifyAuth.getAllPlaylistTrackUris(pid);
stationTrackCache[stationId] = uris;
return uris;
} catch (e) {
console.warn(`Could not load track cache for ${stationId}`, e);
const empty = new Set();
stationTrackCache[stationId] = empty;
return empty;
}
}
const findMix = (id) => data.playlists.find((p) => p.id === id);
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
// ── Scene management ──
function activateScene(sceneName) {
document.querySelectorAll('.scene').forEach((el) => {
el.classList.toggle('--active', el.dataset.scene === sceneName);
});
// Re-seed highway streaks if entering highway
if (sceneName === 'highway') seedHighwayStreaks();
}
function seedHighwayStreaks() {
if (!els.streaksHost) return;
els.streaksHost.innerHTML = '';
const COUNT = 18;
for (let i = 0; i < COUNT; i++) {
const s = document.createElement('span');
s.className = 'streak' + (Math.random() < 0.35 ? ' streak--cool' : '');
const top = 35 + Math.random() * 40; // 35–75% (focus toward horizon)
const dur = 2.4 + Math.random() * 3.6; // 2.4–6s
const delay = -Math.random() * dur; // randomize start so streaks aren't synced
s.style.setProperty('--top', `${top}%`);
s.style.setProperty('--dur', `${dur}s`);
s.style.setProperty('--delay', `${delay}s`);
s.style.width = `${18 + Math.random() * 24}vw`;
els.streaksHost.appendChild(s);
}
}
// ── Render station ──
function renderStation(mix) {
els.stationName.textContent = mix.title;
els.stationSub.textContent = mix.subtitle || '';
activateScene(mix.scene || 'highway');
// First-run state: station has no Spotify URI yet (template default)
if (!mix.spotifyUri) {
els.cardTitle.textContent = `Set up "${mix.title}"`;
els.cardArtist.textContent = 'Press ⌘K and ask the DJ to fill it in';
els.cardArt.style.backgroundImage = '';
els.cardArt.classList.remove('--has-art');
els.cardProgress.style.width = '0%';
return;
}
// Reset card display
if (!SpotifyAuth.isAuthed() || !currentTrack) {
els.cardTitle.textContent = mix.title;
els.cardArtist.textContent = mix.subtitle || '';
els.cardArt.style.backgroundImage = '';
els.cardArt.classList.remove('--has-art');
els.cardProgress.style.width = '0%';
}
}
function setPlayingVisual(playing) {
isPlaying = playing;
els.ctrlPlay.textContent = playing ? '⏸' : '▶';
if (window.SpotifyAuth && SpotifyAuth.isAuthed()) {
if (playing) startPolling(); else stopPolling();
}
renderConnectPrompt();
}
function renderConnectPrompt() {
const authed = window.SpotifyAuth && SpotifyAuth.isAuthed();
els.connectPrompt.hidden = !(isPlaying && !authed);
}
function formatTime(ms) {
const s = Math.max(0, Math.floor((ms || 0) / 1000));
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, '0')}`;
}
function renderHotcorner() {
const authed = window.SpotifyAuth && SpotifyAuth.isAuthed();
els.hotcorner.classList.toggle('--unsaved', !authed);
els.hotcorner.title = authed
? `Auto-saving on · ${liked.length} saved this session`
: 'Set up Spotify auto-save';
}
function renderTrackOnCard(t) {
const isNewTrack = !currentTrack || currentTrack.uri !== t.uri;
currentTrack = t;
updateMediaSessionMetadata(t);
els.cardTitle.textContent = t.name || '—';
els.cardArtist.textContent = t.artist || '—';
els.cardArtist.classList.remove('--timer');
els.cardTitle.classList.remove('--swapping');
els.cardArtist.classList.remove('--swapping');
if (t.artUrl) {
els.cardArt.style.backgroundImage = `url(${JSON.stringify(t.artUrl)})`;
els.cardArt.classList.add('--has-art');
els.cardArt.classList.remove('--swapping');
} else {
els.cardArt.classList.remove('--has-art');
}
if (t.durationMs > 0) {
const pct = Math.max(0, Math.min(100, (t.progressMs / t.durationMs) * 100));
els.cardProgress.style.width = `${pct}%`;
}
if (isNewTrack) {
console.log('[card] new track on deck:', t.name, '·', t.artist);
updateArtTint(t);
}
}
// ── Album-art tint ──
// Loads the album art, samples pixel colors via canvas, applies an
// overlay-blend tint to the .art-tint div.
async function updateArtTint(track) {
const s = loadSettings();
if (!s.albumArtTint || !track || !track.artUrl) {
els.artTint.style.backgroundColor = 'transparent';
return;
}
try {
const color = await extractDominantColor(track.artUrl);
if (color) els.artTint.style.backgroundColor = color;
} catch (e) {
console.warn('[tint] extract failed', e);
}
}
function extractDominantColor(imageUrl) {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
const t = setTimeout(() => resolve(null), 3500);
img.onload = () => {
clearTimeout(t);
try {
const canvas = document.createElement('canvas');
canvas.width = 32; canvas.height = 32;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, 32, 32);
const data = ctx.getImageData(0, 0, 32, 32).data;
let r = 0, g = 0, b = 0, n = 0;
// Average only mid-luminance pixels — skip pure black backgrounds
// and white text/borders so the tint reflects the album's true hue.
for (let i = 0; i < data.length; i += 4) {
const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
if (lum < 30 || lum > 235) continue;
r += data[i]; g += data[i + 1]; b += data[i + 2];
n++;
}
if (n === 0) {
// All pixels filtered — fall back to plain average
for (let i = 0; i < data.length; i += 4) {
r += data[i]; g += data[i + 1]; b += data[i + 2];
n++;
}
}
resolve(`rgb(${Math.round(r / n)},${Math.round(g / n)},${Math.round(b / n)})`);
} catch (e) {
resolve(null);
}
};
img.onerror = () => { clearTimeout(t); resolve(null); };
img.src = imageUrl;
});
}
// ── MediaSession (hardware media keys: F7/F8/F9, lock screen, Touch Bar) ──
function setupMediaSession() {
if (!('mediaSession' in navigator)) {
console.log('[mediasession] not supported');
return;
}
try {
navigator.mediaSession.setActionHandler('previoustrack', () => {
console.log('[mediasession] previoustrack');
skipPrev();
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
console.log('[mediasession] nexttrack');
skipNext();
});
navigator.mediaSession.setActionHandler('play', () => {
console.log('[mediasession] play');
if (!isPlaying) togglePlay();
});
navigator.mediaSession.setActionHandler('pause', () => {
console.log('[mediasession] pause');
if (isPlaying) togglePlay();
});
navigator.mediaSession.setActionHandler('seekto', (e) => {
if (sdkPlayer && e.seekTime != null) {
try { sdkPlayer.seek(Math.round(e.seekTime * 1000)); } catch (err) {}
}
});
console.log('[mediasession] handlers registered');
} catch (e) {
console.warn('[mediasession] setup failed', e);
}
}
function updateMediaSessionMetadata(track) {
if (!('mediaSession' in navigator) || !track) return;
try {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.name || '',
artist: track.artist || '',
album: (findMix(activeId) && findMix(activeId).title) || 'Mix Generator',
artwork: track.artUrl
? [
{ src: track.artUrl, sizes: '300x300', type: 'image/jpeg' },
{ src: track.artUrl, sizes: '640x640', type: 'image/jpeg' },
]
: [],
});
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
if (track.durationMs > 0) {
try {
navigator.mediaSession.setPositionState({
duration: track.durationMs / 1000,
position: (track.progressMs || 0) / 1000,
playbackRate: 1.0,
});
} catch (e) { /* some browsers throw if position > duration */ }
}
} catch (e) {
console.warn('[mediasession] metadata failed', e);
}
}
// ── Web API polling (only when authed) ──
async function pollCurrentlyPlaying() {
try {
const resp = await SpotifyAuth.getCurrentlyPlaying();
if (!resp || !resp.item) { lastSeenTrack = null; return; }
const item = resp.item;
const cur = {
uri: item.uri,
id: item.id,
name: item.name,
artist: (item.artists || []).map((a) => a.name).join(', '),
artUrl: (item.album && item.album.images && item.album.images[0] && item.album.images[0].url) || null,
durationMs: item.duration_ms,
progressMs: resp.progress_ms || 0,
isPlaying: resp.is_playing,
polledAt: Date.now(),
};
// Stale-poll guard: during the station-switch window (8s), Spotify
// often keeps returning the previous track while the iframe loads.
// Drop those updates so the OLD album art doesn't repopulate.
if (Date.now() < postSwitchUntil && cur.uri === preSwitchUri) {
console.log('[poll] stale track during switch, ignoring:', cur.name);
return;
}
// The first non-stale track confirms the switch — clear the guard
if (postSwitchUntil > 0 && cur.uri !== preSwitchUri) {
postSwitchUntil = 0;
preSwitchUri = null;
}
console.log('[poll] track:', cur.name, '·', cur.artist);
renderTrackOnCard(cur);
if (lastSeenTrack && lastSeenTrack.uri !== cur.uri) {
const remainingBefore = lastSeenTrack.durationMs - lastSeenTrack.progressMs;
const elapsedReal = cur.polledAt - lastSeenTrack.polledAt;
const ratio = lastSeenTrack.durationMs > 0
? lastSeenTrack.progressMs / lastSeenTrack.durationMs : 0;
// Played through if: >85% heard, OR real wall-time matches what
// would've been needed to play out the remainder (caught the natural transition).
const naturallyEnded = elapsedReal >= remainingBefore - 8000;
const playedThrough = ratio >= 0.85 || naturallyEnded;
if (playedThrough) {
await onTrackListenedThrough(lastSeenTrack);
} else {
await onTrackSkipped(lastSeenTrack);
}
}
lastSeenTrack = cur;
} catch (e) {
if (String(e.message || e).includes('401')) {
SpotifyAuth.clearAuth();
renderHotcorner();
stopPolling();
}
}
}
function startPolling() {
if (pollInterval) return;
pollCurrentlyPlaying();
pollInterval = setInterval(pollCurrentlyPlaying, 2500);
}
function stopPolling() {
if (pollInterval) clearInterval(pollInterval);
pollInterval = null;
}
// Schedule a cascade of polls — used right after an action so the UI
// catches the new track within a second instead of waiting for the next
// 2.5s tick. Cheap (Spotify allows 180/min, we'd use maybe 30).
function pollSoon() {
if (!SpotifyAuth.isAuthed()) return;
setTimeout(pollCurrentlyPlaying, 250);
setTimeout(pollCurrentlyPlaying, 900);
setTimeout(pollCurrentlyPlaying, 1800);
}
function flashButton(btn) {
if (!btn || !btn.animate) return;
btn.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(0.86)' }, { transform: 'scale(1)' }],
{ duration: 180, easing: 'cubic-bezier(0.2, 0, 0.3, 1)' }
);
}
// Action debounce — prevents double-fire when the same media-key event
// fires through BOTH the MediaSession handler AND the keydown handler
// (which both happen on Mac when a hardware media key is pressed).
const actionLocks = { play: 0, next: 0, prev: 0 };
function actionLocked(key) {
const now = Date.now();
if (now < actionLocks[key]) return true;
actionLocks[key] = now + 350;
return false;
}
async function onTrackListenedThrough(track) {
if (liked.some((l) => l.uri === track.uri)) return;
if (inflightAdds.has(track.uri)) return;
inflightAdds.add(track.uri);
const mix = findMix(activeId);
liked.push({
ts: Date.now(),
uri: track.uri,
name: track.name,
artist: track.artist,
durationMs: track.durationMs,
stationId: activeId,
stationTitle: mix ? mix.title : activeId,
});
saveLiked();
renderHotcorner();
const trackId = track.id || (track.uri || '').split(':').pop();
// Best-effort save to user's Liked Songs library. If the dev app is in
// Development Mode + User Management isn't propagating, this 403s — that's
// expected and not actionable per-track. Log only; rely on save_session
// (via MCP, which bypasses the dev app) for actual durable archives.
if (trackId) {
SpotifyAuth.saveTrackToLibrary(trackId)
.then(() => console.log(`[liked] ✓ saved "${track.name}" to Liked Songs`))
.catch((e) => console.warn(`[liked] auto-save 403 (expected if dev app blocked) — use ⌘K "save my session" to archive via MCP:`, e.message || e));
}
// 3. Queue ONE discovery track for the current session
if (mix) {
queueDiscoveryFromSeed(track, mix).catch((e) =>
console.warn('[discover] failed', e.message || e));
}
// 4. Check if this listen-through pushes us past the chapter threshold.
// Counter is DERIVED from `liked` array (just pushed above) minus the
// per-station baseline, so it persists across reloads automatically.
if (mix && mix.spotifyUri && SpotifyAuth.isAuthed()) {
const n = effectiveChapterCount(mix.id);
if (n % 5 === 0 || n === CHAPTER_REGEN_THRESHOLD) {
console.log(`[chapter] "${mix.title}" listen count: ${n}/${CHAPTER_REGEN_THRESHOLD}`);
}
if (n >= CHAPTER_REGEN_THRESHOLD
&& !stationRegenInProgress[mix.id]
&& !stationsAwaitingSwap[mix.id]) {
triggerChapterRegen(mix); // fire and forget
}
}
// 5. If a chapter regen has finished while the current track was playing,
// NOW (between tracks) is the right moment to swap to the new URI —
// no mid-song cut.
if (mix && stationsAwaitingSwap[mix.id] && sdkReady) {
const newUri = stationsAwaitingSwap[mix.id];
delete stationsAwaitingSwap[mix.id];
mix.spotifyUri = newUri;
mix.spotifyUrl = `https://open.spotify.com/playlist/${newUri.split(':').pop()}`;
console.log(`[chapter] ▶ swapping "${mix.title}" to fresh playlist after current track`);
try { await playStation(mix); } catch (e) { console.warn('[chapter] swap failed', e); }
}
inflightAdds.delete(track.uri);
}
// Background chapter regeneration — generates a fresh ~120-track playlist
// via MCP (claude -p), keeping station identity (title/subtitle/scene)
// intact. Goes through /dj. Routes around Spotify dev app restrictions
// entirely since MCP uses Claude.ai's connection.
async function triggerChapterRegen(mix) {
stationRegenInProgress[mix.id] = true;
// Snapshot baseline NOW so we don't re-trigger while regen is in flight.
// Effective listens drops to 0 immediately, ramps back up as user keeps
// listening to the (about-to-be-swapped) playlist.
chapterBaselines[mix.id] = likedCountForStation(mix.id);
persistChapterBaselines();
console.log(`[chapter] generating fresh chapter for "${mix.title}" (baseline=${chapterBaselines[mix.id]})…`);
try {
const profile = buildTasteProfile();
const prompt =
`CHAPTER REGEN for the "${mix.title}" station. The user has listened through ${CHAPTER_REGEN_THRESHOLD}+ tracks ` +
`on this station this session. Generate a FRESH ~120-track playlist via create_playlist that fits the same vibe.\n\n` +
`USE action="create_station" with these exact fields preserved (DO NOT change them):\n` +
` id="${mix.id}"\n` +
` title="${mix.title}"\n` +
` subtitle="${mix.subtitle || ''}"\n` +
` scene="${mix.scene || 'bar'}"\n` +
` coverColors=${JSON.stringify(mix.coverColors || ['#5b2e2e'])}\n\n` +
`Only the playlist URI changes — everything else stays IDENTICAL. ` +
`Prefer tracks/artists DIFFERENT from the user's recent listen-throughs (chapter freshness).`;
const resp = await fetch('/dj', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, profile }),
});
const data = await resp.json();
if (!resp.ok || data.error) throw new Error(data.error || `HTTP ${resp.status}`);
const final = await pollDjJob(data.jobId);
if (final.status !== 'done') throw new Error(final.error || 'regen failed');
const r = final.result || {};
if (r.action === 'updated' && r.id === mix.id && r.spotifyUri) {
// Mark for swap at next track boundary
stationsAwaitingSwap[mix.id] = r.spotifyUri;
console.log(`[chapter] ✓ fresh playlist ready for "${mix.title}" — will swap at next track`);
showAuthToast(`Fresh chapter ready for ${mix.title} — swapping after this track`, 'success');
} else if (r.action === 'created') {
console.warn(`[chapter] expected overwrite, got new station ${r.id}`);
}
} catch (e) {
console.warn(`[chapter] regen failed for "${mix.title}":`, e.message || e);
} finally {
stationRegenInProgress[mix.id] = false;
}
}
// On boot, check every station's effective listen count (derived from
// hydrated `liked` records). If any is already past threshold from prior
// sessions, fire regen now.
function maybeFireOverdueChapter() {
if (!SpotifyAuth.isAuthed()) return;
for (const mix of data.playlists) {
const n = effectiveChapterCount(mix.id);
if (n >= CHAPTER_REGEN_THRESHOLD
&& mix.spotifyUri
&& !stationRegenInProgress[mix.id]
&& !stationsAwaitingSwap[mix.id]) {
console.log(`[chapter] "${mix.title}" at ${n} listens from prior sessions — firing regen now`);
triggerChapterRegen(mix);
}
}
}
function loadKnownArtists() {
try { return new Set(JSON.parse(localStorage.getItem(LS_KNOWN_ARTISTS) || '[]')); }
catch (e) { return new Set(); }
}
function saveKnownArtists(s) {
localStorage.setItem(LS_KNOWN_ARTISTS, JSON.stringify([...s]));
scheduleStateSync();
}
// Find the artist Spotify ID from a SDK current_track object. The SDK
// gives us `t.artists[0].uri` like "spotify:artist:abc123" — pull the id off.
function artistIdFromSdkTrack() {
if (!currentTrack || !currentTrack.uri) return null;
// The SDK track exposes artists with uris. We're storing the joined name
// string in lastSeenTrack.artist so we lost the uri. Pull from SDK state
// on demand instead.
return null; // (we get the id directly inside replenish via getCurrentState)
}
// ── Discovery queueing ──
// When the user listens a track all the way through, we surface ONE
// discovery track by queueing it via the SDK — no playlist mutation.
// The queued track plays next in this session. If the user likes it,
// they hit ♥ (saves to Liked Songs). If not, it's gone after the session.
// 1. Find the seed track's first artist + their genres
// 2. Search Spotify for tracks in that genre
// 3. Hard-veto candidates from skipped artists; bonus for favored artists
// 4. Bonus for new-to-you artists (discovery)
// 5. POST /me/player/queue?uri=<winner> — track plays next in session
async function queueDiscoveryFromSeed(seedTrack, mix) {
if (!SpotifyAuth.isAuthed() || !sdkDeviceId) return;
// We need the seed artist's ID — pull live from SDK state since
// lastSeenTrack only stored the artist name string.
let seedArtistId = null;
let seedArtistName = seedTrack.artist;
try {
const state = await sdkPlayer.getCurrentState();
const prev = state && state.track_window && state.track_window.previous_tracks
&& state.track_window.previous_tracks.find((t) => t.uri === seedTrack.uri);
const cur = state && state.track_window && state.track_window.current_track;
const src = prev || cur;
if (src && src.artists && src.artists[0] && src.artists[0].uri) {
seedArtistId = src.artists[0].uri.split(':').pop();
seedArtistName = src.artists[0].name;
}
} catch (e) { /* */ }
if (!seedArtistId) return;
// Get seed artist genres
let genres = [];
try {
const artist = await SpotifyAuth.getArtist(seedArtistId);
genres = (artist && artist.genres) || [];
console.log(`[replenish] seed artist: ${seedArtistName}, genres:`, genres);
} catch (e) { console.warn('[replenish] artist lookup failed', e); }
// Build a search query: prefer genre, fall back to "similar to X"
let candidates = [];
if (genres.length > 0) {
const q = `genre:"${genres[0]}"`;
try {
const search = await SpotifyAuth.searchTracks(q, 30);
if (search && search.tracks && search.tracks.items) candidates = search.tracks.items;
} catch (e) { /* */ }
}
if (candidates.length === 0) {
// Fall back to "similar to X" semantic search
try {
const search = await SpotifyAuth.searchTracks(seedArtistName, 30);
if (search && search.tracks && search.tracks.items) candidates = search.tracks.items;
} catch (e) { /* */ }
}
if (candidates.length === 0) {
console.log('[replenish] no candidates found');
return;
}
// Build a taste profile from recent listening:
// FAVORED = artist names from the last 20 listen-throughs
// DISFAVORED = artist names from the last 20 skips
// Used to score candidates (favored = bonus, disfavored = hard veto)
const recentListened = liked.slice(-20);
const recentSkipped = skipped.slice(-20);
const splitArtists = (s) =>
String(s || '').split(',').map((a) => a.trim().toLowerCase()).filter(Boolean);
const favoredArtists = new Set();
recentListened.forEach((l) => splitArtists(l.artist).forEach((a) => favoredArtists.add(a)));
const disfavoredArtists = new Set();
recentSkipped.forEach((s) => splitArtists(s.artist).forEach((a) => disfavoredArtists.add(a)));
console.log(`[replenish] taste: ${favoredArtists.size} favored / ${disfavoredArtists.size} disfavored artists`);
// Filter out tracks already in the station + the seed track itself
const cache = await ensureStationTracksLoaded(activeId);
let fresh = candidates.filter((t) =>
!cache.has(t.uri) && t.uri !== seedTrack.uri
);
if (fresh.length === 0) {
console.log('[replenish] all candidates already in station');
return;
}
// HARD VETO: if any of the candidate's artists are in the disfavored set,
// drop it entirely. This is the anti-skip-pattern signal.
const beforeVeto = fresh.length;
fresh = fresh.filter((t) => {
const artistNames = (t.artists || []).map((a) => (a.name || '').toLowerCase());
return !artistNames.some((n) => disfavoredArtists.has(n));
});
if (fresh.length < beforeVeto) {
console.log(`[replenish] vetoed ${beforeVeto - fresh.length} candidate(s) by disfavored artists`);
}
if (fresh.length === 0) {
console.log('[replenish] all remaining candidates were disfavored — no add');
return;
}
// BPM-locked filter (if enabled): keep only candidates within ±5 BPM
// of the seed track. Spotify's audio-features endpoint is deprecated
// for new apps — if it 403s, we silently skip the filter.
const settings = loadSettings();
if (settings.bpmLocked) {
const filtered = await applyBpmFilter(seedTrack, fresh);
if (filtered && filtered.length > 0) {
fresh = filtered;
} else {
console.log('[bpm] filter produced nothing — keeping unfiltered candidates');
}
}
// Score each candidate. Composite signal:
// +15 if the artist appeared in your favored set (listened through before)
// +8 if it's a new-to-you artist (discovery bonus)
// +1.5 if it's NOT the seed artist (variety)
// +pop/20 (light popularity tilt)
// Skipped-artist tracks are already vetoed above — no need to score them.
const knownArtists = loadKnownArtists();
const scored = fresh.map((t) => {
const firstArtist = t.artists && t.artists[0];
const aid = firstArtist && firstArtist.uri && firstArtist.uri.split(':').pop();
const artistNames = (t.artists || []).map((a) => (a.name || '').toLowerCase());
const isFavored = artistNames.some((n) => favoredArtists.has(n));
const isNew = aid && !knownArtists.has(aid);
const isSeedArtist = aid === seedArtistId;
const score =
(isFavored ? 15 : 0) +
(isNew ? 8 : 0) +
(t.popularity || 0) / 20 +
(isSeedArtist ? 0 : 1.5);
return { track: t, score, isFavored, isNew };
}).sort((a, b) => b.score - a.score);
const winner = scored[0];
const aname = (winner.track.artists && winner.track.artists[0] && winner.track.artists[0].name) || '?';
const tags = [
winner.isFavored && 'favored',
winner.isNew && 'new',
].filter(Boolean).join('+') || 'baseline';
// Queue the winner via Spotify Connect — plays next in this session.
// No playlist mutation. If the user likes it, they hit ♥ to save.
try {
await SpotifyAuth.api(
`/me/player/queue?uri=${encodeURIComponent(winner.track.uri)}&device_id=${sdkDeviceId}`,
{ method: 'POST' }
);
console.log(`[discover] ▶ queued "${winner.track.name}" by ${aname} (${tags}, score ${winner.score.toFixed(1)})`);
} catch (e) {
console.warn('[discover] queue failed', e.message || e);
}
// Mark the seed artist as "known" — they've now been heard through.
knownArtists.add(seedArtistId);
saveKnownArtists(knownArtists);
}
async function applyBpmFilter(seedTrack, candidates) {
const seedId = (seedTrack.uri || '').split(':').pop();
if (!seedId) return null;
let seedTempo = null;
try {
const feat = await SpotifyAuth.api(`/audio-features/${seedId}`);
seedTempo = feat && feat.tempo;
} catch (e) {
console.warn('[bpm] seed tempo unavailable (audio-features may be deprecated for your app)', e.message || e);
return null;
}
if (!seedTempo) return null;
const ids = candidates
.slice(0, 100)
.map((c) => c.id || (c.uri || '').split(':').pop())
.filter(Boolean);
if (ids.length === 0) return null;
let features;
try {
features = await SpotifyAuth.api(`/audio-features?ids=${ids.join(',')}`);
} catch (e) {
console.warn('[bpm] candidate tempos unavailable', e.message || e);
return null;
}
const tempoById = {};
(features && features.audio_features || []).forEach((f) => {
if (f && f.id) tempoById[f.id] = f.tempo;
});
const winners = candidates.filter((c) => {
const id = c.id || (c.uri || '').split(':').pop();
const tempo = tempoById[id];
if (tempo == null) return false; // require known tempo for BPM-lock
return Math.abs(tempo - seedTempo) <= 5;
});
console.log(`[bpm] seed ${seedTempo.toFixed(1)} bpm → ${winners.length}/${candidates.length} within ±5`);
return winners;
}
async function onTrackSkipped(track) {
const mix = findMix(activeId);
// Local record only. The recommender's hard-veto uses this set so the
// same artist won't surface as a discovery again. We DO NOT mutate any
// Spotify playlist on skip — stations stay curated/static.
skipped.push({
ts: Date.now(),
uri: track.uri,
name: track.name,
artist: track.artist,
stationId: activeId,
stationTitle: mix ? mix.title : activeId,
});
saveSkipped();
console.log(`[skip] recorded "${track.name}" by ${track.artist} (informs recommender; no Spotify mutation)`);
}
// ── Spotify Web Playback SDK ──
// The SDK registers this browser as its own Spotify Connect device, so we
// don't have to fight the desktop app for control. We become the device,
// and Web API calls (play/pause/next/prev/load-context) hit it directly.
window.onSpotifyWebPlaybackSDKReady = async () => {
if (!SpotifyAuth.isAuthed()) {
console.log('[sdk] not authed yet — will init after Connect');
return;
}
initSpotifySdk();
};
async function initSpotifySdk() {
if (sdkPlayer) return;
if (typeof Spotify === 'undefined' || !Spotify.Player) {
console.log('[sdk] SDK script not loaded yet');
return;
}
console.log('[sdk] initializing player');
sdkPlayer = new Spotify.Player({
name: 'Mix Generator · Radio',
getOAuthToken: async (cb) => {
const tok = await SpotifyAuth.getAccessToken();
cb(tok);
},
volume: 0.6,
});
sdkPlayer.addListener('initialization_error', ({ message }) =>
console.error('[sdk] init error:', message));
sdkPlayer.addListener('authentication_error', ({ message }) => {
// IMPORTANT: don't clear tokens or open overlay here.
// The SDK can fire authentication_error for reasons unrelated to the
// Web API (e.g. Premium not detected, app missing Web Playback SDK
// enabled in dev dashboard). Wiping tokens broke the heart + save
// surfaces that ARE working. Just log; if the user needs to re-auth
// they can double-click the hot corner.
console.error('[sdk] auth error (not clearing tokens):', message);
});
sdkPlayer.addListener('account_error', ({ message }) => {
// Premium not detected. Web Playback SDK won't work but everything