Tools for the Atmosphere tools.slices.network
quickslice atproto html

docs: add teal-scrobble implementation plan

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