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

feat: add teal-scrobble manual scrobbling tool

Single-page HTML tool for manually scrobbling tracks to Teal with
MusicBrainz search-as-you-type. Features:

- OAuth login via quickslice-client-js
- Artist search with autocomplete (300ms debounce)
- Track search filtered by selected artist
- Auto-fill album and duration from MusicBrainz
- Time toggle for "now" or custom datetime
- Recent scrobbles section showing last 3 plays
- Keyboard navigation and click-outside for dropdowns
- Dark music theme matching teal-plays

+1257 -7
+15 -1
index.html
··· 133 133 </g> 134 134 </svg> 135 135 <h1>Tools</h1> 136 - <qs-tangled-stars handle="slices.network" repo="tools" instance="https://quickslice-production-ddc3.up.railway.app" style="margin-bottom: 0.75rem;"></qs-tangled-stars> 136 + <qs-tangled-stars 137 + handle="slices.network" 138 + repo="tools" 139 + instance="https://quickslice-production-ddc3.up.railway.app" 140 + style="margin-bottom: 0.75rem" 141 + ></qs-tangled-stars> 137 142 <p class="tagline">Apps for the Atmosphere</p> 138 143 </header> 139 144 <main class="tools-list"> ··· 144 149 </div> 145 150 <div class="tool-description"> 146 151 Live music feed from the Atmosphere. See what everyone is listening to in real-time. 152 + </div> 153 + </a> 154 + <a href="teal-scrobble" class="tool-card"> 155 + <div class="tool-header"> 156 + <span class="tool-icon">🎧</span> 157 + <span class="tool-name">Teal Scrobble</span> 158 + </div> 159 + <div class="tool-description"> 160 + Manually log what you're listening to. Search MusicBrainz and scrobble to Teal. 147 161 </div> 148 162 </a> 149 163 <a href="statusphere" class="tool-card">
+16 -6
tangled-repos.html
··· 501 501 <p class="tagline">Browse repositories from the Atmosphere</p> 502 502 </header> 503 503 <div class="search-container"> 504 - <input type="text" id="search-input" placeholder="Search... (@user, repo:name, topic:rust)" /> 504 + <input 505 + type="text" 506 + id="search-input" 507 + placeholder="Search... (@user, repo:name, topic:rust)" 508 + /> 505 509 <button id="clear-search" class="hidden" title="Clear search">&times;</button> 506 510 </div> 507 511 <div id="trending-section" class="trending-section hidden"> 508 512 <div class="trending-header"> 509 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 513 + <svg 514 + viewBox="0 0 24 24" 515 + fill="none" 516 + stroke="currentColor" 517 + stroke-width="2" 518 + stroke-linecap="round" 519 + stroke-linejoin="round" 520 + > 510 521 <polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline> 511 522 <polyline points="17 6 23 6 23 12"></polyline> 512 523 </svg> ··· 712 723 } 713 724 714 725 // Sort by star count and return top 10 715 - const sorted = [...starCounts.entries()] 716 - .sort((a, b) => b[1] - a[1]) 717 - .slice(0, 10); 726 + const sorted = [...starCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10); 718 727 719 728 return sorted.map(([uri, count]) => ({ 720 729 ...repoData.get(uri), ··· 985 994 clearBtn.addEventListener("click", clearSearch); 986 995 987 996 // Show loading state 988 - document.getElementById("repo-feed").innerHTML = `<div class="loading-container"><div class="spinner"></div><span>Loading repos...</span></div>`; 997 + document.getElementById("repo-feed").innerHTML = 998 + `<div class="loading-container"><div class="spinner"></div><span>Loading repos...</span></div>`; 989 999 990 1000 // Load both in parallel 991 1001 const [reposResult, trendingResult] = await Promise.allSettled([
+1226
teal-scrobble.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; 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: #1db954; 39 + --accent-hover: #1ed760; 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 + padding: 0.75rem; 176 + cursor: pointer; 177 + border-bottom: 1px solid var(--border); 178 + } 179 + 180 + .autocomplete-item:last-child { 181 + border-bottom: none; 182 + } 183 + 184 + .autocomplete-item:hover, 185 + .autocomplete-item.selected { 186 + background: var(--bg-hover); 187 + } 188 + 189 + .autocomplete-item-title { 190 + font-weight: 500; 191 + color: var(--text-primary); 192 + } 193 + 194 + .autocomplete-item-subtitle { 195 + font-size: 0.75rem; 196 + color: var(--text-secondary); 197 + } 198 + 199 + .autocomplete-status { 200 + padding: 0.75rem; 201 + color: var(--text-secondary); 202 + font-size: 0.875rem; 203 + text-align: center; 204 + } 205 + 206 + /* Selected Tag */ 207 + .selected-tag { 208 + display: inline-flex; 209 + align-items: center; 210 + gap: 0.5rem; 211 + background: var(--bg-hover); 212 + border: 1px solid var(--accent); 213 + border-radius: 0.375rem; 214 + padding: 0.5rem 0.75rem; 215 + color: var(--text-primary); 216 + } 217 + 218 + .selected-tag button { 219 + background: none; 220 + border: none; 221 + color: var(--text-secondary); 222 + cursor: pointer; 223 + font-size: 1.25rem; 224 + line-height: 1; 225 + padding: 0; 226 + } 227 + 228 + .selected-tag button:hover { 229 + color: var(--error-text); 230 + } 231 + 232 + /* Buttons */ 233 + .btn { 234 + padding: 0.75rem 1.5rem; 235 + border: none; 236 + border-radius: 0.375rem; 237 + font-size: 1rem; 238 + font-weight: 500; 239 + cursor: pointer; 240 + transition: 241 + background-color 0.15s, 242 + opacity 0.15s; 243 + width: 100%; 244 + } 245 + 246 + .btn-primary { 247 + background: var(--accent); 248 + color: var(--bg-primary); 249 + } 250 + 251 + .btn-primary:hover { 252 + background: var(--accent-hover); 253 + } 254 + 255 + .btn-primary:disabled { 256 + opacity: 0.5; 257 + cursor: not-allowed; 258 + } 259 + 260 + .btn-secondary { 261 + background: var(--bg-hover); 262 + color: var(--text-primary); 263 + border: 1px solid var(--border); 264 + } 265 + 266 + .btn-secondary:hover { 267 + background: var(--border); 268 + } 269 + 270 + /* User Card */ 271 + .user-card { 272 + display: flex; 273 + align-items: center; 274 + justify-content: space-between; 275 + } 276 + 277 + .user-info { 278 + display: flex; 279 + align-items: center; 280 + gap: 0.75rem; 281 + } 282 + 283 + .user-avatar { 284 + width: 40px; 285 + height: 40px; 286 + border-radius: 50%; 287 + background: var(--bg-hover); 288 + overflow: hidden; 289 + } 290 + 291 + .user-avatar img { 292 + width: 100%; 293 + height: 100%; 294 + object-fit: cover; 295 + } 296 + 297 + .user-name { 298 + font-weight: 600; 299 + } 300 + 301 + .user-handle { 302 + font-size: 0.875rem; 303 + color: var(--text-secondary); 304 + } 305 + 306 + /* Time Toggle */ 307 + .time-toggle { 308 + display: flex; 309 + align-items: center; 310 + gap: 0.75rem; 311 + margin-bottom: 0.5rem; 312 + } 313 + 314 + .time-toggle label { 315 + margin-bottom: 0; 316 + } 317 + 318 + .toggle-switch { 319 + position: relative; 320 + width: 44px; 321 + height: 24px; 322 + background: var(--bg-hover); 323 + border-radius: 12px; 324 + cursor: pointer; 325 + transition: background-color 0.2s; 326 + } 327 + 328 + .toggle-switch.active { 329 + background: var(--accent); 330 + } 331 + 332 + .toggle-switch::after { 333 + content: ""; 334 + position: absolute; 335 + top: 2px; 336 + left: 2px; 337 + width: 20px; 338 + height: 20px; 339 + background: var(--text-primary); 340 + border-radius: 50%; 341 + transition: transform 0.2s; 342 + } 343 + 344 + .toggle-switch.active::after { 345 + transform: translateX(20px); 346 + } 347 + 348 + /* Recent Scrobbles */ 349 + .recent-item { 350 + display: flex; 351 + align-items: center; 352 + gap: 0.75rem; 353 + padding: 0.75rem 0; 354 + border-bottom: 1px solid var(--border); 355 + } 356 + 357 + .recent-item:last-child { 358 + border-bottom: none; 359 + padding-bottom: 0; 360 + } 361 + 362 + .recent-item:first-child { 363 + padding-top: 0; 364 + } 365 + 366 + .recent-info { 367 + flex: 1; 368 + min-width: 0; 369 + } 370 + 371 + .recent-track { 372 + font-weight: 500; 373 + white-space: nowrap; 374 + overflow: hidden; 375 + text-overflow: ellipsis; 376 + } 377 + 378 + .recent-artist { 379 + font-size: 0.875rem; 380 + color: var(--text-secondary); 381 + white-space: nowrap; 382 + overflow: hidden; 383 + text-overflow: ellipsis; 384 + } 385 + 386 + .recent-time { 387 + font-size: 0.75rem; 388 + color: var(--text-secondary); 389 + flex-shrink: 0; 390 + } 391 + 392 + /* Toast */ 393 + #toast { 394 + position: fixed; 395 + bottom: 2rem; 396 + left: 50%; 397 + transform: translateX(-50%); 398 + padding: 0.75rem 1.5rem; 399 + border-radius: 0.5rem; 400 + font-weight: 500; 401 + z-index: 100; 402 + transition: opacity 0.3s; 403 + } 404 + 405 + #toast.hidden { 406 + opacity: 0; 407 + pointer-events: none; 408 + } 409 + 410 + #toast.success { 411 + background: var(--success-bg); 412 + border: 1px solid var(--success-border); 413 + color: var(--success-text); 414 + } 415 + 416 + #toast.error { 417 + background: var(--error-bg); 418 + border: 1px solid var(--error-border); 419 + color: var(--error-text); 420 + } 421 + 422 + /* Spinner */ 423 + .spinner { 424 + width: 20px; 425 + height: 20px; 426 + border: 2px solid var(--border); 427 + border-top-color: var(--bg-primary); 428 + border-radius: 50%; 429 + animation: spin 0.8s linear infinite; 430 + display: inline-block; 431 + vertical-align: middle; 432 + } 433 + 434 + @keyframes spin { 435 + to { 436 + transform: rotate(360deg); 437 + } 438 + } 439 + 440 + .hidden { 441 + display: none !important; 442 + } 443 + </style> 444 + </head> 445 + <body> 446 + <div id="app"> 447 + <header> 448 + <h1>Teal Scrobble</h1> 449 + <p class="tagline">Manually log what you're listening to</p> 450 + </header> 451 + <main> 452 + <div id="auth-section"></div> 453 + <div id="scrobble-form"></div> 454 + <div id="recent-scrobbles"></div> 455 + </main> 456 + <div id="toast" class="hidden"></div> 457 + </div> 458 + 459 + <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> 460 + <script> 461 + // ============================================================================= 462 + // CONFIGURATION 463 + // ============================================================================= 464 + 465 + const SERVER_URL = "https://fmteal.slices.network"; 466 + const CLIENT_ID = "client_B6LdE1mw4EM-YT7gS5d6yw"; 467 + 468 + // ============================================================================= 469 + // STATE 470 + // ============================================================================= 471 + 472 + const state = { 473 + client: null, 474 + user: null, 475 + selectedArtist: null, 476 + selectedRecording: null, 477 + recentScrobbles: [], 478 + isSubmitting: false, 479 + useCustomTime: false, 480 + }; 481 + 482 + // ============================================================================= 483 + // HELPERS 484 + // ============================================================================= 485 + 486 + function esc(str) { 487 + const d = document.createElement("div"); 488 + d.textContent = str; 489 + return d.innerHTML; 490 + } 491 + 492 + function showToast(message, type = "success") { 493 + const toast = document.getElementById("toast"); 494 + toast.textContent = message; 495 + toast.className = type; 496 + setTimeout(() => toast.classList.add("hidden"), 3000); 497 + } 498 + 499 + // ============================================================================= 500 + // MUSICBRAINZ API 501 + // ============================================================================= 502 + 503 + let searchTimeout = null; 504 + const MB_API = "https://musicbrainz.org/ws/2"; 505 + const MB_HEADERS = { Accept: "application/json" }; 506 + 507 + async function searchArtists(query) { 508 + if (query.length < 2) return []; 509 + 510 + const url = `${MB_API}/artist?query=${encodeURIComponent(query)}&fmt=json&limit=5`; 511 + const res = await fetch(url, { headers: MB_HEADERS }); 512 + 513 + if (!res.ok) throw new Error("MusicBrainz search failed"); 514 + 515 + const data = await res.json(); 516 + return data.artists || []; 517 + } 518 + 519 + async function searchRecordings(query, artistMbid) { 520 + if (query.length < 2) return []; 521 + 522 + const fullQuery = `${query} AND arid:${artistMbid}`; 523 + const url = `${MB_API}/recording?query=${encodeURIComponent(fullQuery)}&fmt=json&limit=8`; 524 + const res = await fetch(url, { headers: MB_HEADERS }); 525 + 526 + if (!res.ok) throw new Error("MusicBrainz search failed"); 527 + 528 + const data = await res.json(); 529 + return data.recordings || []; 530 + } 531 + 532 + function debounce(fn, ms) { 533 + return (...args) => { 534 + clearTimeout(searchTimeout); 535 + searchTimeout = setTimeout(() => fn(...args), ms); 536 + }; 537 + } 538 + 539 + // ============================================================================= 540 + // INITIALIZATION 541 + // ============================================================================= 542 + 543 + async function main() { 544 + // Handle OAuth callback 545 + if (window.location.search.includes("code=")) { 546 + if (!CLIENT_ID) { 547 + showToast("CLIENT_ID not configured", "error"); 548 + renderLoginForm(); 549 + return; 550 + } 551 + 552 + try { 553 + state.client = await QuicksliceClient.createQuicksliceClient({ 554 + server: SERVER_URL, 555 + clientId: CLIENT_ID, 556 + }); 557 + await state.client.handleRedirectCallback(); 558 + window.history.replaceState({}, "", window.location.pathname); 559 + } catch (error) { 560 + console.error("OAuth callback error:", error); 561 + showToast("Authentication failed", "error"); 562 + renderLoginForm(); 563 + return; 564 + } 565 + } else if (CLIENT_ID) { 566 + try { 567 + state.client = await QuicksliceClient.createQuicksliceClient({ 568 + server: SERVER_URL, 569 + clientId: CLIENT_ID, 570 + }); 571 + } catch (error) { 572 + console.error("Failed to initialize client:", error); 573 + } 574 + } 575 + 576 + await renderApp(); 577 + } 578 + 579 + async function renderApp() { 580 + const isLoggedIn = state.client && (await state.client.isAuthenticated()); 581 + 582 + if (isLoggedIn) { 583 + try { 584 + state.user = await fetchViewer(); 585 + renderUserCard(); 586 + renderScrobbleForm(); 587 + await loadRecentScrobbles(); 588 + } catch (error) { 589 + console.error("Failed to load user data:", error); 590 + renderLoginForm(); 591 + } 592 + } else { 593 + renderLoginForm(); 594 + document.getElementById("scrobble-form").innerHTML = ""; 595 + document.getElementById("recent-scrobbles").innerHTML = ""; 596 + } 597 + } 598 + 599 + // ============================================================================= 600 + // DATA FETCHING 601 + // ============================================================================= 602 + 603 + async function fetchViewer() { 604 + const query = ` 605 + query { 606 + viewer { 607 + did 608 + handle 609 + appBskyActorProfileByDid { 610 + displayName 611 + avatar { url(preset: "avatar") } 612 + } 613 + } 614 + } 615 + `; 616 + const data = await state.client.query(query); 617 + return data?.viewer; 618 + } 619 + 620 + // ============================================================================= 621 + // EVENT HANDLERS 622 + // ============================================================================= 623 + 624 + async function handleLogin(event) { 625 + event.preventDefault(); 626 + const handle = document.getElementById("handle").value.trim(); 627 + 628 + if (!handle) { 629 + showToast("Please enter your handle", "error"); 630 + return; 631 + } 632 + 633 + try { 634 + state.client = await QuicksliceClient.createQuicksliceClient({ 635 + server: SERVER_URL, 636 + clientId: CLIENT_ID, 637 + }); 638 + await state.client.loginWithRedirect({ handle }); 639 + } catch (error) { 640 + showToast("Login failed: " + error.message, "error"); 641 + } 642 + } 643 + 644 + function handleLogout() { 645 + if (state.client) { 646 + state.client.logout(); 647 + } 648 + window.location.reload(); 649 + } 650 + 651 + // ============================================================================= 652 + // RENDERING 653 + // ============================================================================= 654 + 655 + function renderLoginForm() { 656 + const container = document.getElementById("auth-section"); 657 + 658 + if (!CLIENT_ID) { 659 + container.innerHTML = ` 660 + <div class="card"> 661 + <p style="color: var(--error-text); text-align: center; margin-bottom: 0.5rem;"> 662 + <strong>Configuration Required</strong> 663 + </p> 664 + <p style="color: var(--text-secondary); text-align: center; font-size: 0.875rem;"> 665 + Set the <code style="background: var(--bg-hover); padding: 0.125rem 0.375rem; border-radius: 0.25rem;">CLIENT_ID</code> constant in this file. 666 + </p> 667 + </div> 668 + `; 669 + return; 670 + } 671 + 672 + container.innerHTML = ` 673 + <div class="card"> 674 + <form onsubmit="handleLogin(event)"> 675 + <div class="form-group"> 676 + <label for="handle">Bluesky Handle</label> 677 + <input type="text" id="handle" placeholder="you.bsky.social" required /> 678 + </div> 679 + <button type="submit" class="btn btn-primary">Login with Teal</button> 680 + </form> 681 + </div> 682 + `; 683 + } 684 + 685 + function renderUserCard() { 686 + const container = document.getElementById("auth-section"); 687 + const profile = state.user?.appBskyActorProfileByDid; 688 + const displayName = profile?.displayName || state.user?.handle || "User"; 689 + const handle = state.user?.handle || "unknown"; 690 + const avatar = profile?.avatar?.url || ""; 691 + 692 + container.innerHTML = ` 693 + <div class="card user-card"> 694 + <div class="user-info"> 695 + <div class="user-avatar"> 696 + ${avatar ? `<img src="${esc(avatar)}" alt="">` : ""} 697 + </div> 698 + <div> 699 + <div class="user-name">${esc(displayName)}</div> 700 + <div class="user-handle">@${esc(handle)}</div> 701 + </div> 702 + </div> 703 + <button class="btn btn-secondary" onclick="handleLogout()" style="width: auto; padding: 0.5rem 1rem; font-size: 0.875rem;">Logout</button> 704 + </div> 705 + `; 706 + } 707 + 708 + function renderScrobbleForm() { 709 + const container = document.getElementById("scrobble-form"); 710 + 711 + container.innerHTML = ` 712 + <div class="card"> 713 + <div class="card-title">Scrobble a Track</div> 714 + 715 + <div class="form-group"> 716 + <label>Artist</label> 717 + <div id="artist-field"></div> 718 + </div> 719 + 720 + <div class="form-group"> 721 + <label>Track</label> 722 + <div id="track-field"></div> 723 + </div> 724 + 725 + <div class="form-row"> 726 + <div class="form-group"> 727 + <label>Album</label> 728 + <input type="text" id="album-display" class="read-only" readonly placeholder="Auto-filled" /> 729 + </div> 730 + <div class="form-group" style="flex: 0 0 80px;"> 731 + <label>Duration</label> 732 + <input type="text" id="duration-display" class="read-only" readonly placeholder="--:--" /> 733 + </div> 734 + </div> 735 + 736 + <div class="form-group"> 737 + <label>Music Service</label> 738 + <input type="text" id="service-domain" placeholder="e.g., spotify.com, music.apple.com (optional)" /> 739 + </div> 740 + 741 + <div class="form-group"> 742 + <div class="time-toggle"> 743 + <label>Played time</label> 744 + <div id="time-toggle" class="toggle-switch" onclick="toggleCustomTime()"></div> 745 + <span style="font-size: 0.875rem; color: var(--text-secondary);" id="time-label">Now</span> 746 + </div> 747 + <input type="datetime-local" id="custom-time" class="hidden" /> 748 + </div> 749 + 750 + <button id="submit-btn" class="btn btn-primary" onclick="handleSubmit()" disabled> 751 + Scrobble 752 + </button> 753 + </div> 754 + `; 755 + 756 + renderArtistField(); 757 + renderTrackField(); 758 + } 759 + 760 + function renderArtistField() { 761 + const container = document.getElementById("artist-field"); 762 + 763 + if (state.selectedArtist) { 764 + container.innerHTML = ` 765 + <div class="selected-tag"> 766 + <span>${esc(state.selectedArtist.name)}</span> 767 + <button onclick="clearArtist()">&times;</button> 768 + </div> 769 + `; 770 + } else { 771 + container.innerHTML = ` 772 + <div class="autocomplete-wrapper"> 773 + <input 774 + type="text" 775 + id="artist-input" 776 + placeholder="Search for an artist..." 777 + oninput="handleArtistInput(this.value)" 778 + onfocus="handleArtistInput(this.value)" 779 + /> 780 + <div id="artist-dropdown" class="autocomplete-dropdown hidden"></div> 781 + </div> 782 + `; 783 + } 784 + } 785 + 786 + function renderTrackField() { 787 + const container = document.getElementById("track-field"); 788 + 789 + if (state.selectedRecording) { 790 + container.innerHTML = ` 791 + <div class="selected-tag"> 792 + <span>${esc(state.selectedRecording.title)}</span> 793 + <button onclick="clearRecording()">&times;</button> 794 + </div> 795 + `; 796 + } else { 797 + const disabled = !state.selectedArtist; 798 + container.innerHTML = ` 799 + <div class="autocomplete-wrapper"> 800 + <input 801 + type="text" 802 + id="track-input" 803 + placeholder="${disabled ? "Select an artist first" : "Search for a track..."}" 804 + ${disabled ? "disabled" : ""} 805 + oninput="handleTrackInput(this.value)" 806 + onfocus="handleTrackInput(this.value)" 807 + /> 808 + <div id="track-dropdown" class="autocomplete-dropdown hidden"></div> 809 + </div> 810 + `; 811 + } 812 + } 813 + 814 + // ============================================================================= 815 + // ARTIST SEARCH HANDLERS 816 + // ============================================================================= 817 + 818 + const handleArtistInput = debounce(async (query) => { 819 + const dropdown = document.getElementById("artist-dropdown"); 820 + 821 + if (query.length < 2) { 822 + dropdown.classList.add("hidden"); 823 + return; 824 + } 825 + 826 + dropdown.innerHTML = `<div class="autocomplete-status">Searching...</div>`; 827 + dropdown.classList.remove("hidden"); 828 + 829 + try { 830 + const artists = await searchArtists(query); 831 + 832 + if (artists.length === 0) { 833 + dropdown.innerHTML = `<div class="autocomplete-status">No artists found</div>`; 834 + return; 835 + } 836 + 837 + dropdown.innerHTML = artists 838 + .map( 839 + (a, i) => ` 840 + <div class="autocomplete-item" onclick="selectArtist(${i})" data-index="${i}"> 841 + <div class="autocomplete-item-title">${esc(a.name)}</div> 842 + ${a.disambiguation ? `<div class="autocomplete-item-subtitle">${esc(a.disambiguation)}</div>` : ""} 843 + </div> 844 + `, 845 + ) 846 + .join(""); 847 + 848 + // Store artists for selection 849 + dropdown.dataset.artists = JSON.stringify(artists); 850 + } catch (error) { 851 + dropdown.innerHTML = `<div class="autocomplete-status" style="color: var(--error-text)">Search failed</div>`; 852 + } 853 + }, 300); 854 + 855 + function selectArtist(index) { 856 + const dropdown = document.getElementById("artist-dropdown"); 857 + const artists = JSON.parse(dropdown.dataset.artists || "[]"); 858 + const artist = artists[index]; 859 + 860 + if (!artist) return; 861 + 862 + state.selectedArtist = { 863 + name: artist.name, 864 + mbid: artist.id, 865 + }; 866 + 867 + state.selectedRecording = null; 868 + document.getElementById("album-display").value = ""; 869 + document.getElementById("duration-display").value = ""; 870 + 871 + renderArtistField(); 872 + renderTrackField(); 873 + updateSubmitButton(); 874 + } 875 + 876 + function clearArtist() { 877 + state.selectedArtist = null; 878 + state.selectedRecording = null; 879 + document.getElementById("album-display").value = ""; 880 + document.getElementById("duration-display").value = ""; 881 + 882 + renderArtistField(); 883 + renderTrackField(); 884 + updateSubmitButton(); 885 + } 886 + 887 + function updateSubmitButton() { 888 + const btn = document.getElementById("submit-btn"); 889 + btn.disabled = !state.selectedArtist || !state.selectedRecording || state.isSubmitting; 890 + } 891 + 892 + // ============================================================================= 893 + // TRACK SEARCH HANDLERS 894 + // ============================================================================= 895 + 896 + const handleTrackInput = debounce(async (query) => { 897 + const dropdown = document.getElementById("track-dropdown"); 898 + 899 + if (!state.selectedArtist || query.length < 2) { 900 + dropdown.classList.add("hidden"); 901 + return; 902 + } 903 + 904 + dropdown.innerHTML = `<div class="autocomplete-status">Searching...</div>`; 905 + dropdown.classList.remove("hidden"); 906 + 907 + try { 908 + const recordings = await searchRecordings(query, state.selectedArtist.mbid); 909 + 910 + if (recordings.length === 0) { 911 + dropdown.innerHTML = `<div class="autocomplete-status">No tracks found</div>`; 912 + return; 913 + } 914 + 915 + dropdown.innerHTML = recordings 916 + .map((r, i) => { 917 + const release = r.releases?.[0]; 918 + const duration = r.length ? formatDuration(Math.floor(r.length / 1000)) : ""; 919 + const album = release?.title || ""; 920 + 921 + return ` 922 + <div class="autocomplete-item" onclick="selectRecording(${i})" data-index="${i}"> 923 + <div class="autocomplete-item-title">${esc(r.title)} ${duration ? `<span style="color: var(--text-secondary); font-weight: normal;">${duration}</span>` : ""}</div> 924 + ${album ? `<div class="autocomplete-item-subtitle">${esc(album)}</div>` : ""} 925 + </div> 926 + `; 927 + }) 928 + .join(""); 929 + 930 + dropdown.dataset.recordings = JSON.stringify(recordings); 931 + } catch (error) { 932 + dropdown.innerHTML = `<div class="autocomplete-status" style="color: var(--error-text)">Search failed</div>`; 933 + } 934 + }, 300); 935 + 936 + function selectRecording(index) { 937 + const dropdown = document.getElementById("track-dropdown"); 938 + const recordings = JSON.parse(dropdown.dataset.recordings || "[]"); 939 + const recording = recordings[index]; 940 + 941 + if (!recording) return; 942 + 943 + const release = recording.releases?.[0]; 944 + const durationSecs = recording.length ? Math.floor(recording.length / 1000) : null; 945 + 946 + state.selectedRecording = { 947 + title: recording.title, 948 + mbid: recording.id, 949 + releaseName: release?.title || null, 950 + releaseMbid: release?.id || null, 951 + duration: durationSecs, 952 + artists: recording["artist-credit"]?.map((ac) => ({ 953 + artistName: ac.artist.name, 954 + artistMbId: ac.artist.id, 955 + })) || [{ artistName: state.selectedArtist.name, artistMbId: state.selectedArtist.mbid }], 956 + }; 957 + 958 + document.getElementById("album-display").value = state.selectedRecording.releaseName || ""; 959 + document.getElementById("duration-display").value = durationSecs 960 + ? formatDuration(durationSecs) 961 + : ""; 962 + 963 + renderTrackField(); 964 + updateSubmitButton(); 965 + } 966 + 967 + function clearRecording() { 968 + state.selectedRecording = null; 969 + document.getElementById("album-display").value = ""; 970 + document.getElementById("duration-display").value = ""; 971 + 972 + renderTrackField(); 973 + updateSubmitButton(); 974 + } 975 + 976 + function formatDuration(secs) { 977 + if (!secs) return ""; 978 + const m = Math.floor(secs / 60); 979 + const s = secs % 60; 980 + return `${m}:${s.toString().padStart(2, "0")}`; 981 + } 982 + 983 + // ============================================================================= 984 + // TIME TOGGLE AND SUBMIT 985 + // ============================================================================= 986 + 987 + function toggleCustomTime() { 988 + state.useCustomTime = !state.useCustomTime; 989 + 990 + const toggle = document.getElementById("time-toggle"); 991 + const label = document.getElementById("time-label"); 992 + const input = document.getElementById("custom-time"); 993 + 994 + toggle.classList.toggle("active", state.useCustomTime); 995 + label.textContent = state.useCustomTime ? "Custom" : "Now"; 996 + input.classList.toggle("hidden", !state.useCustomTime); 997 + 998 + if (state.useCustomTime && !input.value) { 999 + const now = new Date(); 1000 + now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); 1001 + input.value = now.toISOString().slice(0, 16); 1002 + } 1003 + } 1004 + 1005 + async function handleSubmit() { 1006 + if (!state.selectedArtist || !state.selectedRecording || state.isSubmitting) return; 1007 + 1008 + state.isSubmitting = true; 1009 + updateSubmitButton(); 1010 + 1011 + const btn = document.getElementById("submit-btn"); 1012 + btn.innerHTML = `<span class="spinner"></span> Scrobbling...`; 1013 + 1014 + try { 1015 + const playedTime = state.useCustomTime 1016 + ? new Date(document.getElementById("custom-time").value).toISOString() 1017 + : new Date().toISOString(); 1018 + 1019 + const serviceDomain = document.getElementById("service-domain").value.trim() || null; 1020 + 1021 + const input = { 1022 + trackName: state.selectedRecording.title, 1023 + recordingMbId: state.selectedRecording.mbid, 1024 + artists: state.selectedRecording.artists, 1025 + playedTime, 1026 + submissionClientAgent: "slices-tools-scrobbler/0.1.0", 1027 + }; 1028 + 1029 + if (state.selectedRecording.duration) { 1030 + input.duration = state.selectedRecording.duration; 1031 + } 1032 + if (state.selectedRecording.releaseName) { 1033 + input.releaseName = state.selectedRecording.releaseName; 1034 + } 1035 + if (state.selectedRecording.releaseMbid) { 1036 + input.releaseMbId = state.selectedRecording.releaseMbid; 1037 + } 1038 + if (serviceDomain) { 1039 + input.musicServiceBaseDomain = serviceDomain; 1040 + } 1041 + 1042 + const mutation = ` 1043 + mutation CreatePlay($input: FmTealAlphaFeedPlayInput!) { 1044 + createFmTealAlphaFeedPlay(input: $input) { 1045 + uri 1046 + trackName 1047 + } 1048 + } 1049 + `; 1050 + 1051 + await state.client.mutate(mutation, { input }); 1052 + 1053 + showToast("Scrobbled!", "success"); 1054 + 1055 + // Reset form 1056 + state.selectedArtist = null; 1057 + state.selectedRecording = null; 1058 + state.useCustomTime = false; 1059 + document.getElementById("service-domain").value = ""; 1060 + 1061 + renderScrobbleForm(); 1062 + await loadRecentScrobbles(); 1063 + } catch (error) { 1064 + console.error("Submit failed:", error); 1065 + showToast("Failed to scrobble: " + error.message, "error"); 1066 + } finally { 1067 + state.isSubmitting = false; 1068 + updateSubmitButton(); 1069 + const btn = document.getElementById("submit-btn"); 1070 + if (btn) btn.textContent = "Scrobble"; 1071 + } 1072 + } 1073 + 1074 + async function loadRecentScrobbles() { 1075 + const container = document.getElementById("recent-scrobbles"); 1076 + 1077 + container.innerHTML = ` 1078 + <div class="card"> 1079 + <div class="card-title">Your Recent Scrobbles</div> 1080 + <p style="color: var(--text-secondary); font-size: 0.875rem;">Loading...</p> 1081 + </div> 1082 + `; 1083 + 1084 + try { 1085 + const query = ` 1086 + query { 1087 + viewer { 1088 + fmTealAlphaFeedPlayByDid( 1089 + first: 3 1090 + sortBy: [{ field: playedTime, direction: DESC }] 1091 + ) { 1092 + edges { 1093 + node { 1094 + trackName 1095 + artistNames 1096 + artists { artistName } 1097 + releaseName 1098 + playedTime 1099 + } 1100 + } 1101 + } 1102 + } 1103 + } 1104 + `; 1105 + 1106 + const data = await state.client.query(query); 1107 + const plays = data?.viewer?.fmTealAlphaFeedPlayByDid?.edges?.map((e) => e.node) || []; 1108 + state.recentScrobbles = plays; 1109 + 1110 + renderRecentScrobbles(); 1111 + } catch (error) { 1112 + console.error("Failed to load recent scrobbles:", error); 1113 + container.innerHTML = ` 1114 + <div class="card"> 1115 + <div class="card-title">Your Recent Scrobbles</div> 1116 + <p style="color: var(--error-text); font-size: 0.875rem;">Failed to load</p> 1117 + </div> 1118 + `; 1119 + } 1120 + } 1121 + 1122 + function renderRecentScrobbles() { 1123 + const container = document.getElementById("recent-scrobbles"); 1124 + 1125 + if (state.recentScrobbles.length === 0) { 1126 + container.innerHTML = ` 1127 + <div class="card"> 1128 + <div class="card-title">Your Recent Scrobbles</div> 1129 + <p style="color: var(--text-secondary); font-size: 0.875rem;">No scrobbles yet. Start logging!</p> 1130 + </div> 1131 + `; 1132 + return; 1133 + } 1134 + 1135 + const items = state.recentScrobbles 1136 + .map((play) => { 1137 + const artists = 1138 + play.artists?.map((a) => a.artistName).join(", ") || 1139 + play.artistNames?.join(", ") || 1140 + "Unknown Artist"; 1141 + 1142 + return ` 1143 + <div class="recent-item"> 1144 + <div class="recent-info"> 1145 + <div class="recent-track">${esc(play.trackName)}</div> 1146 + <div class="recent-artist">${esc(artists)}</div> 1147 + </div> 1148 + <div class="recent-time">${formatTimeAgo(play.playedTime)}</div> 1149 + </div> 1150 + `; 1151 + }) 1152 + .join(""); 1153 + 1154 + container.innerHTML = ` 1155 + <div class="card"> 1156 + <div class="card-title">Your Recent Scrobbles</div> 1157 + ${items} 1158 + </div> 1159 + `; 1160 + } 1161 + 1162 + function formatTimeAgo(iso) { 1163 + const d = new Date(iso); 1164 + const now = new Date(); 1165 + const diff = Math.floor((now - d) / 1000); 1166 + 1167 + if (diff < 60) return "just now"; 1168 + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; 1169 + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; 1170 + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 1171 + } 1172 + 1173 + // ============================================================================= 1174 + // GLOBAL EVENT HANDLERS 1175 + // ============================================================================= 1176 + 1177 + document.addEventListener("click", (e) => { 1178 + // Close dropdowns when clicking outside 1179 + if (!e.target.closest(".autocomplete-wrapper")) { 1180 + document.querySelectorAll(".autocomplete-dropdown").forEach((d) => { 1181 + d.classList.add("hidden"); 1182 + }); 1183 + } 1184 + }); 1185 + 1186 + document.addEventListener("keydown", (e) => { 1187 + const activeDropdown = document.querySelector(".autocomplete-dropdown:not(.hidden)"); 1188 + if (!activeDropdown) return; 1189 + 1190 + const items = activeDropdown.querySelectorAll(".autocomplete-item"); 1191 + if (items.length === 0) return; 1192 + 1193 + const selected = activeDropdown.querySelector(".autocomplete-item.selected"); 1194 + let currentIndex = selected ? Array.from(items).indexOf(selected) : -1; 1195 + 1196 + if (e.key === "ArrowDown") { 1197 + e.preventDefault(); 1198 + currentIndex = Math.min(currentIndex + 1, items.length - 1); 1199 + } else if (e.key === "ArrowUp") { 1200 + e.preventDefault(); 1201 + currentIndex = Math.max(currentIndex - 1, 0); 1202 + } else if (e.key === "Enter" && selected) { 1203 + e.preventDefault(); 1204 + selected.click(); 1205 + return; 1206 + } else if (e.key === "Escape") { 1207 + activeDropdown.classList.add("hidden"); 1208 + return; 1209 + } else { 1210 + return; 1211 + } 1212 + 1213 + items.forEach((item, i) => { 1214 + item.classList.toggle("selected", i === currentIndex); 1215 + }); 1216 + 1217 + if (items[currentIndex]) { 1218 + items[currentIndex].scrollIntoView({ block: "nearest" }); 1219 + } 1220 + }); 1221 + 1222 + // Start the app 1223 + main(); 1224 + </script> 1225 + </body> 1226 + </html>