Tools for the Atmosphere
tools.slices.network
quickslice
atproto
html
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <meta
7 http-equiv="Content-Security-Policy"
8 content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; connect-src 'self' https://fmteal.slices.network https://musicbrainz.org https://public.api.bsky.app; img-src 'self' https: data:;"
9 />
10 <title>Teal Scrobble</title>
11 <style>
12 /* CSS Reset */
13 *,
14 *::before,
15 *::after {
16 box-sizing: border-box;
17 }
18 * {
19 margin: 0;
20 }
21 body {
22 line-height: 1.5;
23 -webkit-font-smoothing: antialiased;
24 }
25 input,
26 button {
27 font: inherit;
28 }
29
30 /* Dark Music Theme */
31 :root {
32 --bg-primary: #0a0a0a;
33 --bg-card: #161616;
34 --bg-hover: #1f1f1f;
35 --bg-input: #1a1a1a;
36 --text-primary: #ffffff;
37 --text-secondary: #a0a0a0;
38 --accent: #14b8a6;
39 --accent-hover: #2dd4bf;
40 --border: #2a2a2a;
41 --error-bg: #2d1f1f;
42 --error-border: #5c2828;
43 --error-text: #ff6b6b;
44 --success-bg: #1f2d1f;
45 --success-border: #285c28;
46 --success-text: #6bff6b;
47 }
48
49 body {
50 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
51 background: var(--bg-primary);
52 color: var(--text-primary);
53 min-height: 100vh;
54 padding: 2rem 1rem;
55 }
56
57 #app {
58 max-width: 500px;
59 margin: 0 auto;
60 }
61
62 header {
63 text-align: center;
64 margin-bottom: 1.5rem;
65 }
66
67 header h1 {
68 font-size: 2rem;
69 color: var(--accent);
70 margin-bottom: 0.25rem;
71 }
72
73 .tagline {
74 color: var(--text-secondary);
75 font-size: 0.875rem;
76 }
77
78 .card {
79 background: var(--bg-card);
80 border-radius: 0.5rem;
81 padding: 1.25rem;
82 margin-bottom: 1rem;
83 border: 1px solid var(--border);
84 }
85
86 .card-title {
87 font-size: 0.875rem;
88 font-weight: 600;
89 color: var(--text-secondary);
90 margin-bottom: 1rem;
91 text-transform: uppercase;
92 letter-spacing: 0.05em;
93 }
94
95 /* Form Styles */
96 .form-group {
97 margin-bottom: 1rem;
98 }
99
100 .form-group:last-child {
101 margin-bottom: 0;
102 }
103
104 .form-group label {
105 display: block;
106 font-size: 0.875rem;
107 font-weight: 500;
108 color: var(--text-secondary);
109 margin-bottom: 0.375rem;
110 }
111
112 .form-group input {
113 width: 100%;
114 padding: 0.75rem;
115 background: var(--bg-input);
116 border: 1px solid var(--border);
117 border-radius: 0.375rem;
118 color: var(--text-primary);
119 font-size: 1rem;
120 }
121
122 .form-group input:focus {
123 outline: none;
124 border-color: var(--accent);
125 }
126
127 .form-group input:disabled {
128 opacity: 0.5;
129 cursor: not-allowed;
130 }
131
132 .form-group input::placeholder {
133 color: var(--text-secondary);
134 opacity: 0.6;
135 }
136
137 .form-row {
138 display: flex;
139 gap: 1rem;
140 }
141
142 .form-row .form-group {
143 flex: 1;
144 }
145
146 .read-only {
147 background: var(--bg-hover);
148 cursor: default;
149 }
150
151 /* Autocomplete Dropdown */
152 .autocomplete-wrapper {
153 position: relative;
154 }
155
156 .autocomplete-dropdown {
157 position: absolute;
158 top: 100%;
159 left: 0;
160 right: 0;
161 background: var(--bg-card);
162 border: 1px solid var(--border);
163 border-top: none;
164 border-radius: 0 0 0.375rem 0.375rem;
165 max-height: 240px;
166 overflow-y: auto;
167 z-index: 10;
168 }
169
170 .autocomplete-dropdown.hidden {
171 display: none;
172 }
173
174 .autocomplete-item {
175 display: flex;
176 align-items: center;
177 gap: 0.75rem;
178 padding: 0.75rem;
179 cursor: pointer;
180 border-bottom: 1px solid var(--border);
181 }
182
183 .autocomplete-item:last-child {
184 border-bottom: none;
185 }
186
187 .autocomplete-item:hover,
188 .autocomplete-item.selected {
189 background: var(--bg-hover);
190 }
191
192 .autocomplete-item-art {
193 width: 40px;
194 height: 40px;
195 border-radius: 0.25rem;
196 background: var(--bg-hover);
197 flex-shrink: 0;
198 overflow: hidden;
199 }
200
201 .autocomplete-item-art img {
202 width: 100%;
203 height: 100%;
204 object-fit: cover;
205 }
206
207 .autocomplete-item-info {
208 flex: 1;
209 min-width: 0;
210 }
211
212 .autocomplete-item-title {
213 font-weight: 500;
214 color: var(--text-primary);
215 }
216
217 .autocomplete-item-subtitle {
218 font-size: 0.75rem;
219 color: var(--text-secondary);
220 }
221
222 .autocomplete-status {
223 padding: 0.75rem;
224 color: var(--text-secondary);
225 font-size: 0.875rem;
226 text-align: center;
227 }
228
229 /* Selected Tag */
230 .selected-tag {
231 display: flex;
232 align-items: center;
233 justify-content: space-between;
234 gap: 0.5rem;
235 background: var(--bg-hover);
236 border: 1px solid var(--accent);
237 border-radius: 0.375rem;
238 padding: 0.75rem;
239 color: var(--text-primary);
240 width: 100%;
241 }
242
243 .selected-tag button {
244 background: none;
245 border: none;
246 color: var(--text-secondary);
247 cursor: pointer;
248 font-size: 1.25rem;
249 line-height: 1;
250 padding: 0;
251 }
252
253 .selected-tag button:hover {
254 color: var(--error-text);
255 }
256
257 /* Buttons */
258 .btn {
259 padding: 0.75rem 1.5rem;
260 border: none;
261 border-radius: 0.375rem;
262 font-size: 1rem;
263 font-weight: 500;
264 cursor: pointer;
265 transition:
266 background-color 0.15s,
267 opacity 0.15s;
268 width: 100%;
269 }
270
271 .btn-primary {
272 background: var(--accent);
273 color: var(--bg-primary);
274 }
275
276 .btn-primary:hover {
277 background: var(--accent-hover);
278 }
279
280 .btn-primary:disabled {
281 opacity: 0.5;
282 cursor: not-allowed;
283 }
284
285 .btn-secondary {
286 background: var(--bg-hover);
287 color: var(--text-primary);
288 border: 1px solid var(--border);
289 }
290
291 .btn-secondary:hover {
292 background: var(--border);
293 }
294
295 /* User Card */
296 .user-card {
297 display: flex;
298 align-items: center;
299 justify-content: space-between;
300 }
301
302 .user-info {
303 display: flex;
304 align-items: center;
305 gap: 0.75rem;
306 }
307
308 .user-avatar {
309 width: 40px;
310 height: 40px;
311 border-radius: 50%;
312 background: var(--bg-hover);
313 overflow: hidden;
314 }
315
316 .user-avatar img {
317 width: 100%;
318 height: 100%;
319 object-fit: cover;
320 }
321
322 .user-name {
323 font-weight: 600;
324 }
325
326 .user-handle {
327 font-size: 0.875rem;
328 color: var(--text-secondary);
329 }
330
331 /* Time Toggle */
332 .time-toggle {
333 display: flex;
334 align-items: center;
335 gap: 0.75rem;
336 margin-bottom: 0.5rem;
337 }
338
339 .time-toggle label {
340 margin-bottom: 0;
341 }
342
343 .toggle-switch {
344 position: relative;
345 width: 44px;
346 height: 24px;
347 background: var(--bg-hover);
348 border-radius: 12px;
349 cursor: pointer;
350 transition: background-color 0.2s;
351 }
352
353 .toggle-switch.active {
354 background: var(--accent);
355 }
356
357 .toggle-switch::after {
358 content: "";
359 position: absolute;
360 top: 2px;
361 left: 2px;
362 width: 20px;
363 height: 20px;
364 background: var(--text-primary);
365 border-radius: 50%;
366 transition: transform 0.2s;
367 }
368
369 .toggle-switch.active::after {
370 transform: translateX(20px);
371 }
372
373 /* Recent Scrobbles */
374 .recent-item {
375 display: flex;
376 align-items: center;
377 gap: 0.75rem;
378 padding: 0.75rem 0;
379 border-bottom: 1px solid var(--border);
380 }
381
382 .recent-item:last-child {
383 border-bottom: none;
384 padding-bottom: 0;
385 }
386
387 .recent-item:first-child {
388 padding-top: 0;
389 }
390
391 .recent-art {
392 width: 40px;
393 height: 40px;
394 border-radius: 0.25rem;
395 background: var(--bg-hover);
396 flex-shrink: 0;
397 overflow: hidden;
398 }
399
400 .recent-art img {
401 width: 100%;
402 height: 100%;
403 object-fit: cover;
404 }
405
406 .recent-info {
407 flex: 1;
408 min-width: 0;
409 }
410
411 .recent-track {
412 font-weight: 500;
413 white-space: nowrap;
414 overflow: hidden;
415 text-overflow: ellipsis;
416 }
417
418 .recent-artist {
419 font-size: 0.875rem;
420 color: var(--text-secondary);
421 white-space: nowrap;
422 overflow: hidden;
423 text-overflow: ellipsis;
424 }
425
426 .recent-time {
427 font-size: 0.75rem;
428 color: var(--text-secondary);
429 flex-shrink: 0;
430 }
431
432 /* Toast */
433 #toast {
434 position: fixed;
435 bottom: 2rem;
436 left: 50%;
437 transform: translateX(-50%);
438 padding: 0.75rem 1.5rem;
439 border-radius: 0.5rem;
440 font-weight: 500;
441 z-index: 100;
442 transition: opacity 0.3s;
443 }
444
445 #toast.hidden {
446 opacity: 0;
447 pointer-events: none;
448 }
449
450 #toast.success {
451 background: var(--success-bg);
452 border: 1px solid var(--success-border);
453 color: var(--success-text);
454 }
455
456 #toast.error {
457 background: var(--error-bg);
458 border: 1px solid var(--error-border);
459 color: var(--error-text);
460 }
461
462 /* Spinner */
463 .spinner {
464 width: 20px;
465 height: 20px;
466 border: 2px solid var(--border);
467 border-top-color: var(--bg-primary);
468 border-radius: 50%;
469 animation: spin 0.8s linear infinite;
470 display: inline-block;
471 vertical-align: middle;
472 }
473
474 @keyframes spin {
475 to {
476 transform: rotate(360deg);
477 }
478 }
479
480 .hidden {
481 display: none !important;
482 }
483
484 /* Actor Autocomplete Styling */
485 qs-actor-autocomplete {
486 --qs-input-bg: var(--bg-input);
487 --qs-input-border: var(--border);
488 --qs-input-border-focus: var(--accent);
489 --qs-input-text: var(--text-primary);
490 --qs-input-placeholder: var(--text-secondary);
491 --qs-input-padding: 0.75rem;
492 --qs-menu-bg: var(--bg-card);
493 --qs-menu-border: var(--border);
494 --qs-menu-shadow: rgba(0, 0, 0, 0.3);
495 --qs-item-hover: var(--bg-hover);
496 --qs-avatar-bg: var(--bg-hover);
497 --qs-handle-color: var(--text-primary);
498 --qs-name-color: var(--text-secondary);
499 --qs-radius: 0.375rem;
500 display: block;
501 width: 100%;
502 }
503 </style>
504 </head>
505 <body>
506 <div id="app">
507 <header>
508 <h1>Teal Scrobble</h1>
509 <p class="tagline">Manually log what you're listening to</p>
510 </header>
511 <main>
512 <div id="auth-section"></div>
513 <div id="scrobble-form"></div>
514 <div id="recent-scrobbles"></div>
515 </main>
516 <div id="toast" class="hidden"></div>
517 </div>
518
519 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script>
520 <script src="https://cdn.jsdelivr.net/gh/bigmoves/elements@v0.1.0/dist/elements.min.js"></script>
521 <script>
522 // =============================================================================
523 // CONFIGURATION
524 // =============================================================================
525
526 const SERVER_URL = "https://fmteal.slices.network";
527 const CLIENT_ID = "client_B6LdE1mw4EM-YT7gS5d6yw";
528
529 // =============================================================================
530 // STATE
531 // =============================================================================
532
533 const state = {
534 client: null,
535 user: null,
536 selectedArtist: null,
537 selectedRecording: null,
538 recentScrobbles: [],
539 isSubmitting: false,
540 useCustomTime: false,
541 };
542
543 // =============================================================================
544 // HELPERS
545 // =============================================================================
546
547 function esc(str) {
548 const d = document.createElement("div");
549 d.textContent = str;
550 return d.innerHTML;
551 }
552
553 function showToast(message, type = "success") {
554 const toast = document.getElementById("toast");
555 toast.textContent = message;
556 toast.className = type;
557 setTimeout(() => toast.classList.add("hidden"), 3000);
558 }
559
560 // =============================================================================
561 // MUSICBRAINZ API
562 // =============================================================================
563
564 let searchTimeout = null;
565 const MB_API = "https://musicbrainz.org/ws/2";
566 const MB_HEADERS = { Accept: "application/json" };
567
568 async function searchArtists(query) {
569 if (query.length < 2) return [];
570
571 const url = `${MB_API}/artist?query=${encodeURIComponent(query)}&fmt=json&limit=10`;
572 const res = await fetch(url, { headers: MB_HEADERS });
573
574 if (!res.ok) throw new Error("MusicBrainz search failed");
575
576 const data = await res.json();
577 return data.artists || [];
578 }
579
580 async function searchRecordings(query, artistMbid) {
581 if (query.length < 2) return [];
582
583 const fullQuery = `${query} AND arid:${artistMbid}`;
584 const url = `${MB_API}/recording?query=${encodeURIComponent(fullQuery)}&fmt=json&limit=50`;
585 const res = await fetch(url, { headers: MB_HEADERS });
586
587 if (!res.ok) throw new Error("MusicBrainz search failed");
588
589 const data = await res.json();
590 return data.recordings || [];
591 }
592
593 function debounce(fn, ms) {
594 return (...args) => {
595 clearTimeout(searchTimeout);
596 searchTimeout = setTimeout(() => fn(...args), ms);
597 };
598 }
599
600 // =============================================================================
601 // INITIALIZATION
602 // =============================================================================
603
604 async function main() {
605 // Handle OAuth callback
606 if (window.location.search.includes("code=")) {
607 if (!CLIENT_ID) {
608 showToast("CLIENT_ID not configured", "error");
609 renderLoginForm();
610 return;
611 }
612
613 try {
614 state.client = await QuicksliceClient.createQuicksliceClient({
615 server: SERVER_URL,
616 clientId: CLIENT_ID,
617 });
618 await state.client.handleRedirectCallback();
619 window.history.replaceState({}, "", window.location.pathname);
620 } catch (error) {
621 console.error("OAuth callback error:", error);
622 showToast("Authentication failed", "error");
623 renderLoginForm();
624 return;
625 }
626 } else if (CLIENT_ID) {
627 try {
628 state.client = await QuicksliceClient.createQuicksliceClient({
629 server: SERVER_URL,
630 clientId: CLIENT_ID,
631 });
632 } catch (error) {
633 console.error("Failed to initialize client:", error);
634 }
635 }
636
637 await renderApp();
638 }
639
640 async function renderApp() {
641 const isLoggedIn = state.client && (await state.client.isAuthenticated());
642
643 if (isLoggedIn) {
644 try {
645 state.user = await fetchViewer();
646 renderUserCard();
647 renderScrobbleForm();
648 await loadRecentScrobbles();
649 } catch (error) {
650 console.error("Failed to load user data:", error);
651 renderLoginForm();
652 }
653 } else {
654 renderLoginForm();
655 document.getElementById("scrobble-form").innerHTML = "";
656 document.getElementById("recent-scrobbles").innerHTML = "";
657 }
658 }
659
660 // =============================================================================
661 // DATA FETCHING
662 // =============================================================================
663
664 async function fetchViewer() {
665 const query = `
666 query {
667 viewer {
668 did
669 handle
670 appBskyActorProfileByDid {
671 displayName
672 avatar { url(preset: "avatar") }
673 }
674 }
675 }
676 `;
677 const data = await state.client.query(query);
678 return data?.viewer;
679 }
680
681 // =============================================================================
682 // EVENT HANDLERS
683 // =============================================================================
684
685 async function handleLogin(event) {
686 event.preventDefault();
687 const handle = document.getElementById("handle").value.trim();
688
689 if (!handle) {
690 showToast("Please enter your handle", "error");
691 return;
692 }
693
694 try {
695 state.client = await QuicksliceClient.createQuicksliceClient({
696 server: SERVER_URL,
697 clientId: CLIENT_ID,
698 });
699 await state.client.loginWithRedirect({ handle });
700 } catch (error) {
701 showToast("Login failed: " + error.message, "error");
702 }
703 }
704
705 function handleLogout() {
706 if (state.client) {
707 state.client.logout();
708 }
709 window.location.reload();
710 }
711
712 // =============================================================================
713 // RENDERING
714 // =============================================================================
715
716 function renderLoginForm() {
717 const container = document.getElementById("auth-section");
718
719 if (!CLIENT_ID) {
720 container.innerHTML = `
721 <div class="card">
722 <p style="color: var(--error-text); text-align: center; margin-bottom: 0.5rem;">
723 <strong>Configuration Required</strong>
724 </p>
725 <p style="color: var(--text-secondary); text-align: center; font-size: 0.875rem;">
726 Set the <code style="background: var(--bg-hover); padding: 0.125rem 0.375rem; border-radius: 0.25rem;">CLIENT_ID</code> constant in this file.
727 </p>
728 </div>
729 `;
730 return;
731 }
732
733 container.innerHTML = `
734 <div class="card">
735 <form onsubmit="handleLogin(event)">
736 <div class="form-group">
737 <label for="handle">AT Protocol Handle</label>
738 <qs-actor-autocomplete id="handle-autocomplete" placeholder="you.bsky.social"></qs-actor-autocomplete>
739 <input type="hidden" id="handle" />
740 </div>
741 <button type="submit" class="btn btn-primary">Continue</button>
742 </form>
743 </div>
744 `;
745
746 // Set up actor autocomplete event listener
747 const autocomplete = document.getElementById("handle-autocomplete");
748 autocomplete.addEventListener("qs-select", (e) => {
749 document.getElementById("handle").value = e.detail.actor.handle;
750 });
751 autocomplete.addEventListener("input", () => {
752 document.getElementById("handle").value = autocomplete.value;
753 });
754 }
755
756 function renderUserCard() {
757 const container = document.getElementById("auth-section");
758 const profile = state.user?.appBskyActorProfileByDid;
759 const displayName = profile?.displayName || state.user?.handle || "User";
760 const handle = state.user?.handle || "unknown";
761 const avatar = profile?.avatar?.url || "";
762
763 container.innerHTML = `
764 <div class="card user-card">
765 <div class="user-info">
766 <div class="user-avatar">
767 ${avatar ? `<img src="${esc(avatar)}" alt="">` : ""}
768 </div>
769 <div>
770 <div class="user-name">${esc(displayName)}</div>
771 <div class="user-handle">@${esc(handle)}</div>
772 </div>
773 </div>
774 <button class="btn btn-secondary" onclick="handleLogout()" style="width: auto; padding: 0.5rem 1rem; font-size: 0.875rem;">Logout</button>
775 </div>
776 `;
777 }
778
779 function renderScrobbleForm() {
780 const container = document.getElementById("scrobble-form");
781
782 container.innerHTML = `
783 <div class="card">
784 <div class="card-title">Scrobble a Track</div>
785
786 <div class="form-group">
787 <label>Artist</label>
788 <div id="artist-field"></div>
789 </div>
790
791 <div class="form-group">
792 <label>Track</label>
793 <div id="track-field"></div>
794 </div>
795
796 <div class="form-group" style="max-width: 100px;">
797 <label>Duration</label>
798 <input type="text" id="duration-display" class="read-only" readonly placeholder="--:--" />
799 </div>
800
801 <div class="form-group">
802 <label>Music Service</label>
803 <input type="text" id="service-domain" placeholder="e.g., spotify.com, music.apple.com (optional)" />
804 </div>
805
806 <div class="form-group">
807 <div class="time-toggle">
808 <label>Played time</label>
809 <div id="time-toggle" class="toggle-switch" onclick="toggleCustomTime()"></div>
810 <span style="font-size: 0.875rem; color: var(--text-secondary);" id="time-label">Now</span>
811 </div>
812 <input type="datetime-local" id="custom-time" class="hidden" />
813 </div>
814
815 <button id="submit-btn" class="btn btn-primary" onclick="handleSubmit()" disabled>
816 Scrobble
817 </button>
818
819 <p style="margin-top: 1rem; font-size: 0.75rem; color: var(--text-secondary); text-align: center;">
820 Search powered by <a href="https://musicbrainz.org/" target="_blank" rel="noopener" style="color: var(--text-secondary);">MusicBrainz</a>
821 </p>
822 </div>
823 `;
824
825 renderArtistField();
826 renderTrackField();
827 }
828
829 function renderArtistField() {
830 const container = document.getElementById("artist-field");
831
832 if (state.selectedArtist) {
833 container.innerHTML = `
834 <div class="selected-tag">
835 <span>${esc(state.selectedArtist.name)}</span>
836 <button onclick="clearArtist()">×</button>
837 </div>
838 `;
839 } else {
840 container.innerHTML = `
841 <div class="autocomplete-wrapper">
842 <input
843 type="text"
844 id="artist-input"
845 placeholder="Search for an artist..."
846 oninput="handleArtistInput(this.value)"
847 onfocus="handleArtistInput(this.value)"
848 />
849 <div id="artist-dropdown" class="autocomplete-dropdown hidden"></div>
850 </div>
851 `;
852 }
853 }
854
855 function renderTrackField() {
856 const container = document.getElementById("track-field");
857
858 if (state.selectedRecording) {
859 const albumText = state.selectedRecording.releaseName
860 ? ` · ${esc(state.selectedRecording.releaseName)}`
861 : "";
862 container.innerHTML = `
863 <div class="selected-tag">
864 <span>${esc(state.selectedRecording.title)}${albumText}</span>
865 <button onclick="clearRecording()">×</button>
866 </div>
867 `;
868 } else {
869 const disabled = !state.selectedArtist;
870 container.innerHTML = `
871 <div class="autocomplete-wrapper">
872 <input
873 type="text"
874 id="track-input"
875 placeholder="${disabled ? "Select an artist first" : "Search for a track..."}"
876 ${disabled ? "disabled" : ""}
877 oninput="handleTrackInput(this.value)"
878 onfocus="handleTrackInput(this.value)"
879 />
880 <div id="track-dropdown" class="autocomplete-dropdown hidden"></div>
881 </div>
882 `;
883 }
884 }
885
886 // =============================================================================
887 // ARTIST SEARCH HANDLERS
888 // =============================================================================
889
890 const handleArtistInput = debounce(async (query) => {
891 const dropdown = document.getElementById("artist-dropdown");
892
893 if (query.length < 2) {
894 dropdown.classList.add("hidden");
895 return;
896 }
897
898 dropdown.innerHTML = `<div class="autocomplete-status">Searching...</div>`;
899 dropdown.classList.remove("hidden");
900
901 try {
902 const artists = await searchArtists(query);
903
904 if (artists.length === 0) {
905 dropdown.innerHTML = `<div class="autocomplete-status">No artists found</div>`;
906 return;
907 }
908
909 dropdown.innerHTML = artists
910 .map(
911 (a, i) => `
912 <div class="autocomplete-item" onclick="selectArtist(${i})" data-index="${i}">
913 <div class="autocomplete-item-title">${esc(a.name)}</div>
914 ${a.disambiguation ? `<div class="autocomplete-item-subtitle">${esc(a.disambiguation)}</div>` : ""}
915 </div>
916 `,
917 )
918 .join("");
919
920 // Store artists for selection
921 dropdown.dataset.artists = JSON.stringify(artists);
922 } catch (error) {
923 dropdown.innerHTML = `<div class="autocomplete-status" style="color: var(--error-text)">Search failed</div>`;
924 }
925 }, 300);
926
927 function selectArtist(index) {
928 const dropdown = document.getElementById("artist-dropdown");
929 const artists = JSON.parse(dropdown.dataset.artists || "[]");
930 const artist = artists[index];
931
932 if (!artist) return;
933
934 state.selectedArtist = {
935 name: artist.name,
936 mbid: artist.id,
937 };
938
939 state.selectedRecording = null;
940 document.getElementById("duration-display").value = "";
941
942 renderArtistField();
943 renderTrackField();
944 updateSubmitButton();
945 }
946
947 function clearArtist() {
948 state.selectedArtist = null;
949 state.selectedRecording = null;
950 document.getElementById("duration-display").value = "";
951
952 renderArtistField();
953 renderTrackField();
954 updateSubmitButton();
955 }
956
957 function updateSubmitButton() {
958 const btn = document.getElementById("submit-btn");
959 btn.disabled = !state.selectedArtist || !state.selectedRecording || state.isSubmitting;
960 }
961
962 // =============================================================================
963 // TRACK SEARCH HANDLERS
964 // =============================================================================
965
966 const handleTrackInput = debounce(async (query) => {
967 const dropdown = document.getElementById("track-dropdown");
968
969 if (!state.selectedArtist || query.length < 2) {
970 dropdown.classList.add("hidden");
971 return;
972 }
973
974 dropdown.innerHTML = `<div class="autocomplete-status">Searching...</div>`;
975 dropdown.classList.remove("hidden");
976
977 try {
978 const recordings = await searchRecordings(query, state.selectedArtist.mbid);
979
980 if (recordings.length === 0) {
981 dropdown.innerHTML = `<div class="autocomplete-status">No tracks found</div>`;
982 return;
983 }
984
985 // Flatten recordings into recording+release pairs
986 let items = [];
987 recordings.forEach((r) => {
988 const releases = r.releases || [];
989 if (releases.length === 0) {
990 items.push({ recording: r, release: null });
991 } else {
992 releases.forEach((release) => {
993 items.push({ recording: r, release });
994 });
995 }
996 });
997
998 // Sort: prefer official albums without secondary types, then by date
999 items.sort((a, b) => {
1000 const aRelease = a.release || {};
1001 const bRelease = b.release || {};
1002 const aGroup = aRelease["release-group"] || {};
1003 const bGroup = bRelease["release-group"] || {};
1004
1005 // Prefer official status
1006 const aOfficial = aRelease.status === "Official" ? 0 : 1;
1007 const bOfficial = bRelease.status === "Official" ? 0 : 1;
1008 if (aOfficial !== bOfficial) return aOfficial - bOfficial;
1009
1010 // Prefer albums without secondary types (not compilations)
1011 const aIsCompilation = (aGroup["secondary-types"] || []).length > 0 ? 1 : 0;
1012 const bIsCompilation = (bGroup["secondary-types"] || []).length > 0 ? 1 : 0;
1013 if (aIsCompilation !== bIsCompilation) return aIsCompilation - bIsCompilation;
1014
1015 // Prefer primary type "Album"
1016 const aIsAlbum = aGroup["primary-type"] === "Album" ? 0 : 1;
1017 const bIsAlbum = bGroup["primary-type"] === "Album" ? 0 : 1;
1018 if (aIsAlbum !== bIsAlbum) return aIsAlbum - bIsAlbum;
1019
1020 // Prefer earlier release date (original release)
1021 const aDate = aRelease.date || "9999";
1022 const bDate = bRelease.date || "9999";
1023 return aDate.localeCompare(bDate);
1024 });
1025
1026 // Dedupe after sorting - keep first (best) occurrence of each track+album
1027 const seen = new Set();
1028 items = items.filter((item) => {
1029 const key = `${item.recording.title}|${item.release?.title || ""}`;
1030 if (seen.has(key)) return false;
1031 seen.add(key);
1032 return true;
1033 });
1034
1035 dropdown.innerHTML = items
1036 .map((item, i) => {
1037 const r = item.recording;
1038 const release = item.release;
1039 const duration = r.length ? formatDuration(Math.floor(r.length / 1000)) : "";
1040 const album = release?.title || "";
1041 const artUrl = release?.id
1042 ? `https://coverartarchive.org/release/${release.id}/front-250`
1043 : "";
1044
1045 return `
1046 <div class="autocomplete-item" onclick="selectRecordingItem(${i})" data-index="${i}">
1047 <div class="autocomplete-item-art">
1048 ${artUrl ? `<img src="${artUrl}" alt="" onerror="this.style.display='none'">` : ""}
1049 </div>
1050 <div class="autocomplete-item-info">
1051 <div class="autocomplete-item-title">${esc(r.title)}${album ? ` <span style="color: var(--text-secondary); font-weight: normal;">· ${esc(album)}</span>` : ""}</div>
1052 ${duration ? `<div class="autocomplete-item-subtitle">${duration}</div>` : ""}
1053 </div>
1054 </div>
1055 `;
1056 })
1057 .join("");
1058
1059 dropdown.dataset.items = JSON.stringify(items);
1060 } catch (error) {
1061 dropdown.innerHTML = `<div class="autocomplete-status" style="color: var(--error-text)">Search failed</div>`;
1062 }
1063 }, 300);
1064
1065 function selectRecordingItem(index) {
1066 const dropdown = document.getElementById("track-dropdown");
1067 const items = JSON.parse(dropdown.dataset.items || "[]");
1068 const item = items[index];
1069
1070 if (!item) return;
1071
1072 const recording = item.recording;
1073 const release = item.release;
1074 const durationSecs = recording.length ? Math.floor(recording.length / 1000) : null;
1075
1076 state.selectedRecording = {
1077 title: recording.title,
1078 mbid: recording.id,
1079 releaseName: release?.title || null,
1080 releaseMbid: release?.id || null,
1081 duration: durationSecs,
1082 artists: recording["artist-credit"]?.map((ac) => ({
1083 artistName: ac.artist.name,
1084 artistMbId: ac.artist.id,
1085 })) || [{ artistName: state.selectedArtist.name, artistMbId: state.selectedArtist.mbid }],
1086 };
1087
1088 document.getElementById("duration-display").value = durationSecs
1089 ? formatDuration(durationSecs)
1090 : "";
1091
1092 renderTrackField();
1093 updateSubmitButton();
1094 }
1095
1096 function clearRecording() {
1097 state.selectedRecording = null;
1098 document.getElementById("duration-display").value = "";
1099
1100 renderTrackField();
1101 updateSubmitButton();
1102 }
1103
1104 function formatDuration(secs) {
1105 if (!secs) return "";
1106 const m = Math.floor(secs / 60);
1107 const s = secs % 60;
1108 return `${m}:${s.toString().padStart(2, "0")}`;
1109 }
1110
1111 // =============================================================================
1112 // TIME TOGGLE AND SUBMIT
1113 // =============================================================================
1114
1115 function toggleCustomTime() {
1116 state.useCustomTime = !state.useCustomTime;
1117
1118 const toggle = document.getElementById("time-toggle");
1119 const label = document.getElementById("time-label");
1120 const input = document.getElementById("custom-time");
1121
1122 toggle.classList.toggle("active", state.useCustomTime);
1123 label.textContent = state.useCustomTime ? "Custom" : "Now";
1124 input.classList.toggle("hidden", !state.useCustomTime);
1125
1126 if (state.useCustomTime && !input.value) {
1127 const now = new Date();
1128 now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
1129 input.value = now.toISOString().slice(0, 16);
1130 }
1131 }
1132
1133 async function handleSubmit() {
1134 if (!state.selectedArtist || !state.selectedRecording || state.isSubmitting) return;
1135
1136 state.isSubmitting = true;
1137 updateSubmitButton();
1138
1139 const btn = document.getElementById("submit-btn");
1140 btn.innerHTML = `<span class="spinner"></span> Scrobbling...`;
1141
1142 try {
1143 const playedTime = state.useCustomTime
1144 ? new Date(document.getElementById("custom-time").value).toISOString()
1145 : new Date().toISOString();
1146
1147 const serviceDomain = document.getElementById("service-domain").value.trim() || null;
1148
1149 const input = {
1150 trackName: state.selectedRecording.title,
1151 recordingMbId: state.selectedRecording.mbid,
1152 artists: state.selectedRecording.artists,
1153 playedTime,
1154 submissionClientAgent: "slices-tools-scrobbler/0.1.0",
1155 };
1156
1157 if (state.selectedRecording.duration) {
1158 input.duration = state.selectedRecording.duration;
1159 }
1160 if (state.selectedRecording.releaseName) {
1161 input.releaseName = state.selectedRecording.releaseName;
1162 }
1163 if (state.selectedRecording.releaseMbid) {
1164 input.releaseMbId = state.selectedRecording.releaseMbid;
1165 }
1166 if (serviceDomain) {
1167 input.musicServiceBaseDomain = serviceDomain;
1168 }
1169
1170 const mutation = `
1171 mutation CreatePlay($input: FmTealAlphaFeedPlayInput!) {
1172 createFmTealAlphaFeedPlay(input: $input) {
1173 uri
1174 trackName
1175 }
1176 }
1177 `;
1178
1179 await state.client.mutate(mutation, { input });
1180
1181 showToast("Scrobbled!", "success");
1182
1183 // Reset form
1184 state.selectedArtist = null;
1185 state.selectedRecording = null;
1186 state.useCustomTime = false;
1187 document.getElementById("service-domain").value = "";
1188
1189 renderScrobbleForm();
1190 await loadRecentScrobbles();
1191 } catch (error) {
1192 console.error("Submit failed:", error);
1193 showToast("Failed to scrobble: " + error.message, "error");
1194 } finally {
1195 state.isSubmitting = false;
1196 updateSubmitButton();
1197 const btn = document.getElementById("submit-btn");
1198 if (btn) btn.textContent = "Scrobble";
1199 }
1200 }
1201
1202 async function loadRecentScrobbles() {
1203 const container = document.getElementById("recent-scrobbles");
1204
1205 container.innerHTML = `
1206 <div class="card">
1207 <div class="card-title">Your Recent Scrobbles</div>
1208 <p style="color: var(--text-secondary); font-size: 0.875rem;">Loading...</p>
1209 </div>
1210 `;
1211
1212 try {
1213 const query = `
1214 query {
1215 viewer {
1216 fmTealAlphaFeedPlayByDid(
1217 first: 3
1218 sortBy: [{ field: playedTime, direction: DESC }]
1219 ) {
1220 edges {
1221 node {
1222 trackName
1223 artistNames
1224 artists { artistName }
1225 releaseName
1226 releaseMbId
1227 playedTime
1228 }
1229 }
1230 }
1231 }
1232 }
1233 `;
1234
1235 const data = await state.client.query(query);
1236 const plays = data?.viewer?.fmTealAlphaFeedPlayByDid?.edges?.map((e) => e.node) || [];
1237 state.recentScrobbles = plays;
1238
1239 renderRecentScrobbles();
1240 } catch (error) {
1241 console.error("Failed to load recent scrobbles:", error);
1242 container.innerHTML = `
1243 <div class="card">
1244 <div class="card-title">Your Recent Scrobbles</div>
1245 <p style="color: var(--error-text); font-size: 0.875rem;">Failed to load</p>
1246 </div>
1247 `;
1248 }
1249 }
1250
1251 function renderRecentScrobbles() {
1252 const container = document.getElementById("recent-scrobbles");
1253
1254 if (state.recentScrobbles.length === 0) {
1255 container.innerHTML = `
1256 <div class="card">
1257 <div class="card-title">Your Recent Scrobbles</div>
1258 <p style="color: var(--text-secondary); font-size: 0.875rem;">No scrobbles yet. Start logging!</p>
1259 </div>
1260 `;
1261 return;
1262 }
1263
1264 const items = state.recentScrobbles
1265 .map((play) => {
1266 const artists =
1267 play.artists?.map((a) => a.artistName).join(", ") ||
1268 play.artistNames?.join(", ") ||
1269 "Unknown Artist";
1270 const artUrl = play.releaseMbId
1271 ? `https://coverartarchive.org/release/${play.releaseMbId}/front-250`
1272 : "";
1273
1274 return `
1275 <div class="recent-item">
1276 <div class="recent-art">
1277 ${artUrl ? `<img src="${artUrl}" alt="" onerror="this.style.display='none'">` : ""}
1278 </div>
1279 <div class="recent-info">
1280 <div class="recent-track">${esc(play.trackName)}</div>
1281 <div class="recent-artist">${esc(artists)}</div>
1282 </div>
1283 <div class="recent-time">${formatTimeAgo(play.playedTime)}</div>
1284 </div>
1285 `;
1286 })
1287 .join("");
1288
1289 container.innerHTML = `
1290 <div class="card">
1291 <div class="card-title">Your Recent Scrobbles</div>
1292 ${items}
1293 </div>
1294 `;
1295 }
1296
1297 function formatTimeAgo(iso) {
1298 const d = new Date(iso);
1299 const now = new Date();
1300 const diff = Math.floor((now - d) / 1000);
1301
1302 if (diff < 60) return "just now";
1303 if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
1304 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
1305 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
1306 }
1307
1308 // =============================================================================
1309 // GLOBAL EVENT HANDLERS
1310 // =============================================================================
1311
1312 document.addEventListener("click", (e) => {
1313 // Close dropdowns when clicking outside
1314 if (!e.target.closest(".autocomplete-wrapper")) {
1315 document.querySelectorAll(".autocomplete-dropdown").forEach((d) => {
1316 d.classList.add("hidden");
1317 });
1318 }
1319 });
1320
1321 document.addEventListener("keydown", (e) => {
1322 const activeDropdown = document.querySelector(".autocomplete-dropdown:not(.hidden)");
1323 if (!activeDropdown) return;
1324
1325 const items = activeDropdown.querySelectorAll(".autocomplete-item");
1326 if (items.length === 0) return;
1327
1328 const selected = activeDropdown.querySelector(".autocomplete-item.selected");
1329 let currentIndex = selected ? Array.from(items).indexOf(selected) : -1;
1330
1331 if (e.key === "ArrowDown") {
1332 e.preventDefault();
1333 currentIndex = Math.min(currentIndex + 1, items.length - 1);
1334 } else if (e.key === "ArrowUp") {
1335 e.preventDefault();
1336 currentIndex = Math.max(currentIndex - 1, 0);
1337 } else if (e.key === "Enter" && selected) {
1338 e.preventDefault();
1339 selected.click();
1340 return;
1341 } else if (e.key === "Escape") {
1342 activeDropdown.classList.add("hidden");
1343 return;
1344 } else {
1345 return;
1346 }
1347
1348 items.forEach((item, i) => {
1349 item.classList.toggle("selected", i === currentIndex);
1350 });
1351
1352 if (items[currentIndex]) {
1353 items[currentIndex].scrollIntoView({ block: "nearest" });
1354 }
1355 });
1356
1357 // Start the app
1358 main();
1359 </script>
1360 </body>
1361</html>