Tools for the Atmosphere tools.slices.network
quickslice atproto html
at main 1361 lines 42 kB view raw
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()">&times;</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()">&times;</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>