Tools for the Atmosphere tools.slices.network
quickslice atproto html
at main 767 lines 21 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 wss://fmteal.slices.network https://coverartarchive.org; img-src 'self' https: data:;" 9 /> 10 <title>Teal Plays</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 --text-primary: #ffffff; 36 --text-secondary: #a0a0a0; 37 --accent: #14b8a6; 38 --accent-hover: #2dd4bf; 39 --border: #2a2a2a; 40 --error-bg: #2d1f1f; 41 --error-border: #5c2828; 42 --error-text: #ff6b6b; 43 } 44 45 body { 46 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 47 background: var(--bg-primary); 48 color: var(--text-primary); 49 min-height: 100vh; 50 padding: 2rem 1rem; 51 } 52 53 #app { 54 max-width: 600px; 55 margin: 0 auto; 56 } 57 58 /* Header */ 59 header { 60 text-align: center; 61 margin-bottom: 1.5rem; 62 } 63 64 header h1 { 65 font-size: 2rem; 66 color: var(--accent); 67 margin-bottom: 0.25rem; 68 display: flex; 69 align-items: center; 70 justify-content: center; 71 gap: 0.5rem; 72 } 73 74 .live-dot { 75 width: 8px; 76 height: 8px; 77 background: var(--accent); 78 border-radius: 50%; 79 animation: pulse 2s ease-in-out infinite; 80 } 81 82 .live-dot.off { 83 background: var(--text-secondary); 84 animation: none; 85 } 86 87 @keyframes pulse { 88 0%, 89 100% { 90 opacity: 1; 91 transform: scale(1); 92 } 93 50% { 94 opacity: 0.5; 95 transform: scale(1.2); 96 } 97 } 98 99 .tagline { 100 color: var(--text-secondary); 101 font-size: 0.875rem; 102 } 103 104 /* Buttons */ 105 .btn { 106 padding: 0.75rem 1rem; 107 border: none; 108 border-radius: 0.5rem; 109 font-size: 0.875rem; 110 font-weight: 500; 111 cursor: pointer; 112 transition: 113 background-color 0.15s, 114 opacity 0.15s; 115 } 116 117 .btn-primary { 118 background: var(--accent); 119 color: var(--bg-primary); 120 } 121 .btn-primary:hover { 122 background: var(--accent-hover); 123 } 124 .btn-primary:disabled { 125 opacity: 0.5; 126 cursor: not-allowed; 127 } 128 129 .btn-secondary { 130 background: var(--bg-hover); 131 color: var(--text-primary); 132 border: 1px solid var(--border); 133 } 134 .btn-secondary:hover { 135 background: var(--border); 136 } 137 138 /* Cards */ 139 .card { 140 background: var(--bg-card); 141 border-radius: 0.5rem; 142 padding: 1rem; 143 margin-bottom: 0.75rem; 144 border: 1px solid var(--border); 145 } 146 147 .card.highlight { 148 animation: highlight-fade 2s ease-out; 149 } 150 151 @keyframes highlight-fade { 152 0% { 153 border-color: var(--accent); 154 box-shadow: 0 0 10px rgba(29, 185, 84, 0.3); 155 } 156 100% { 157 border-color: var(--border); 158 box-shadow: none; 159 } 160 } 161 162 /* Play Card */ 163 .play-header { 164 display: flex; 165 align-items: center; 166 gap: 0.75rem; 167 margin-bottom: 0.75rem; 168 } 169 170 .play-avatar { 171 width: 40px; 172 height: 40px; 173 border-radius: 50%; 174 background: var(--bg-hover); 175 overflow: hidden; 176 flex-shrink: 0; 177 } 178 179 .play-avatar img { 180 width: 100%; 181 height: 100%; 182 object-fit: cover; 183 } 184 185 .play-meta { 186 flex: 1; 187 min-width: 0; 188 } 189 190 .play-user { 191 color: var(--accent); 192 text-decoration: none; 193 font-weight: 500; 194 font-size: 0.875rem; 195 } 196 .play-user:hover { 197 text-decoration: underline; 198 } 199 200 .play-time { 201 color: var(--text-secondary); 202 font-size: 0.75rem; 203 } 204 205 .play-track { 206 font-size: 1.125rem; 207 font-weight: 600; 208 margin-bottom: 0.25rem; 209 display: flex; 210 justify-content: space-between; 211 align-items: baseline; 212 gap: 0.5rem; 213 } 214 215 .play-track-name { 216 min-width: 0; 217 overflow: hidden; 218 text-overflow: ellipsis; 219 white-space: nowrap; 220 } 221 222 .play-duration { 223 color: var(--text-secondary); 224 font-size: 0.875rem; 225 font-weight: 400; 226 flex-shrink: 0; 227 } 228 229 .play-artist { 230 color: var(--text-secondary); 231 font-size: 0.875rem; 232 margin-bottom: 0.125rem; 233 } 234 .play-album { 235 color: var(--text-secondary); 236 font-size: 0.75rem; 237 font-style: italic; 238 } 239 240 .play-content { 241 display: flex; 242 gap: 0.75rem; 243 } 244 245 .play-art { 246 width: 64px; 247 height: 64px; 248 border-radius: 0.25rem; 249 background: var(--bg-hover); 250 overflow: hidden; 251 flex-shrink: 0; 252 } 253 254 .play-art img { 255 width: 100%; 256 height: 100%; 257 object-fit: cover; 258 } 259 260 .play-art-fallback { 261 width: 100%; 262 height: 100%; 263 display: flex; 264 align-items: center; 265 justify-content: center; 266 color: var(--text-secondary); 267 } 268 269 .play-art img.hidden { 270 display: none; 271 } 272 .play-art:has(img:not(.hidden)) .play-art-fallback { 273 display: none; 274 } 275 276 .play-info { 277 flex: 1; 278 min-width: 0; 279 } 280 281 .play-links { 282 display: flex; 283 gap: 0.75rem; 284 margin-top: 0.75rem; 285 padding-top: 0.75rem; 286 border-top: 1px solid var(--border); 287 } 288 289 .play-link { 290 color: var(--text-secondary); 291 text-decoration: none; 292 font-size: 0.75rem; 293 } 294 .play-link:hover { 295 color: var(--accent); 296 } 297 298 /* Status */ 299 .status-msg { 300 text-align: center; 301 color: var(--text-secondary); 302 padding: 2rem; 303 } 304 305 .load-more { 306 text-align: center; 307 padding: 1rem; 308 } 309 310 /* Error Banner */ 311 #error-banner { 312 position: fixed; 313 top: 1rem; 314 left: 50%; 315 transform: translateX(-50%); 316 background: var(--error-bg); 317 border: 1px solid var(--error-border); 318 color: var(--error-text); 319 padding: 0.75rem 1rem; 320 border-radius: 0.5rem; 321 display: flex; 322 align-items: center; 323 gap: 0.75rem; 324 max-width: 90%; 325 z-index: 100; 326 } 327 328 #error-banner.hidden { 329 display: none; 330 } 331 #error-banner button { 332 background: none; 333 border: none; 334 color: var(--error-text); 335 cursor: pointer; 336 font-size: 1.25rem; 337 line-height: 1; 338 } 339 340 .hidden { 341 display: none !important; 342 } 343 344 /* Spinner */ 345 .spinner { 346 width: 32px; 347 height: 32px; 348 border: 3px solid var(--border); 349 border-top-color: var(--accent); 350 border-radius: 50%; 351 animation: spin 0.8s linear infinite; 352 margin: 0 auto; 353 } 354 355 @keyframes spin { 356 to { 357 transform: rotate(360deg); 358 } 359 } 360 361 .loading-container { 362 display: flex; 363 flex-direction: column; 364 align-items: center; 365 gap: 0.75rem; 366 padding: 2rem; 367 color: var(--text-secondary); 368 } 369 </style> 370 </head> 371 <body> 372 <div id="app"> 373 <header> 374 <h1> 375 Teal Plays 376 <span id="live-dot" class="live-dot off" title="Real-time updates"></span> 377 </h1> 378 <p class="tagline">Live music feed from the Atmosphere</p> 379 </header> 380 <main> 381 <div id="play-feed"></div> 382 <div id="load-more"></div> 383 </main> 384 <div id="error-banner" class="hidden"></div> 385 </div> 386 387 <!-- Quickslice Client SDK --> 388 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> 389 390 <script> 391 // ============================================================================= 392 // CONFIGURATION 393 // ============================================================================= 394 395 const SERVER_URL = "https://fmteal.slices.network"; 396 const PAGE_SIZE = 20; 397 398 // ============================================================================= 399 // STATE 400 // ============================================================================= 401 402 const state = { 403 plays: [], 404 cursor: null, 405 hasMore: true, 406 isLoading: false, 407 liveConnected: false, 408 }; 409 410 // ============================================================================= 411 // GRAPHQL 412 // ============================================================================= 413 414 const PLAYS_QUERY = ` 415 query GetPlays($first: Int!, $after: String) { 416 fmTealAlphaFeedPlay( 417 first: $first 418 after: $after 419 sortBy: [{ field: playedTime, direction: DESC }] 420 ) { 421 edges { 422 node { 423 uri 424 trackName 425 artistNames 426 artists { artistName artistMbId } 427 releaseName 428 releaseMbId 429 recordingMbId 430 duration 431 playedTime 432 originUrl 433 musicServiceBaseDomain 434 actorHandle 435 appBskyActorProfileByDid { 436 displayName 437 avatar { url(preset: "avatar") } 438 } 439 } 440 } 441 pageInfo { hasNextPage endCursor } 442 } 443 } 444 `; 445 446 const PLAY_SUB = ` 447 subscription { 448 fmTealAlphaFeedPlayCreated { 449 uri 450 trackName 451 artistNames 452 artists { artistName artistMbId } 453 releaseName 454 releaseMbId 455 recordingMbId 456 duration 457 playedTime 458 originUrl 459 musicServiceBaseDomain 460 actorHandle 461 appBskyActorProfileByDid { 462 displayName 463 avatar { url(preset: "avatar") } 464 } 465 } 466 } 467 `; 468 469 // ============================================================================= 470 // DATA FETCHING 471 // ============================================================================= 472 473 async function fetchPlays(cursor = null) { 474 const variables = { first: PAGE_SIZE, after: cursor }; 475 476 const res = await fetch(`${SERVER_URL}/graphql`, { 477 method: "POST", 478 headers: { "Content-Type": "application/json" }, 479 body: JSON.stringify({ query: PLAYS_QUERY, variables }), 480 }); 481 482 if (!res.ok) throw new Error(`HTTP ${res.status}`); 483 484 const json = await res.json(); 485 if (json.errors) throw new Error(json.errors[0].message); 486 487 return json.data.fmTealAlphaFeedPlay; 488 } 489 490 // ============================================================================= 491 // HELPERS 492 // ============================================================================= 493 494 function showError(msg) { 495 const el = document.getElementById("error-banner"); 496 el.innerHTML = `<span>${esc(msg)}</span><button onclick="hideError()">×</button>`; 497 el.classList.remove("hidden"); 498 } 499 500 function hideError() { 501 document.getElementById("error-banner").classList.add("hidden"); 502 } 503 504 function esc(str) { 505 const d = document.createElement("div"); 506 d.textContent = str; 507 return d.innerHTML; 508 } 509 510 function formatDuration(secs) { 511 if (!secs) return null; 512 const m = Math.floor(secs / 60); 513 const s = secs % 60; 514 return `${m}:${s.toString().padStart(2, "0")}`; 515 } 516 517 function formatTime(iso) { 518 const d = new Date(iso); 519 const now = new Date(); 520 const diff = Math.floor((now - d) / 1000); 521 522 if (diff < 60) return "just now"; 523 if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; 524 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; 525 if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; 526 527 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 528 } 529 530 function getArtists(play) { 531 if (play.artists?.length) return play.artists.map((a) => a.artistName).join(", "); 532 if (play.artistNames?.length) return play.artistNames.join(", "); 533 return "Unknown Artist"; 534 } 535 536 function getServiceName(domain) { 537 if (!domain) return null; 538 const map = { 539 "open.spotify.com": "Spotify", 540 "spotify.com": "Spotify", 541 "music.apple.com": "Apple Music", 542 "tidal.com": "Tidal", 543 "deezer.com": "Deezer", 544 "soundcloud.com": "SoundCloud", 545 "music.youtube.com": "YouTube Music", 546 }; 547 return map[domain] || domain; 548 } 549 550 function getMbUrl(play) { 551 if (play.recordingMbId) return `https://musicbrainz.org/recording/${play.recordingMbId}`; 552 if (play.releaseMbId) return `https://musicbrainz.org/release/${play.releaseMbId}`; 553 return null; 554 } 555 556 function getAlbumArtUrl(play) { 557 if (play.releaseMbId) 558 return `https://coverartarchive.org/release/${play.releaseMbId}/front-250`; 559 return ""; 560 } 561 562 // ============================================================================= 563 // RENDERING 564 // ============================================================================= 565 566 function renderPlayCard(play, highlight = false) { 567 const profile = play.appBskyActorProfileByDid; 568 const handle = play.actorHandle || "unknown"; 569 const avatar = profile?.avatar?.url || ""; 570 const duration = formatDuration(play.duration); 571 const artists = getArtists(play); 572 const service = getServiceName(play.musicServiceBaseDomain); 573 const mbUrl = getMbUrl(play); 574 const albumArt = getAlbumArtUrl(play); 575 576 let links = ""; 577 if (play.originUrl && service) { 578 links += `<a href="${esc(play.originUrl)}" target="_blank" class="play-link">${esc(service)}</a>`; 579 } 580 if (mbUrl) { 581 links += `<a href="${mbUrl}" target="_blank" class="play-link">MusicBrainz</a>`; 582 } 583 584 return ` 585 <div class="card${highlight ? " highlight" : ""}" data-uri="${esc(play.uri)}"> 586 <div class="play-header"> 587 <div class="play-avatar"> 588 ${avatar ? `<img src="${esc(avatar)}" alt="">` : ""} 589 </div> 590 <div class="play-meta"> 591 <a href="https://bsky.app/profile/${esc(handle)}" target="_blank" class="play-user">@${esc(handle)}</a> 592 <div class="play-time">${formatTime(play.playedTime)}</div> 593 </div> 594 </div> 595 <div class="play-content"> 596 <div class="play-art"> 597 ${albumArt ? `<img src="${esc(albumArt)}" alt="" onerror="this.classList.add('hidden')">` : ""} 598 <div class="play-art-fallback"> 599 <svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg> 600 </div> 601 </div> 602 <div class="play-info"> 603 <div class="play-track"> 604 <span class="play-track-name">${esc(play.trackName)}</span> 605 ${duration ? `<span class="play-duration">${duration}</span>` : ""} 606 </div> 607 <div class="play-artist">${esc(artists)}</div> 608 ${play.releaseName ? `<div class="play-album">${esc(play.releaseName)}</div>` : ""} 609 </div> 610 </div> 611 ${links ? `<div class="play-links">${links}</div>` : ""} 612 </div> 613 `; 614 } 615 616 // ============================================================================= 617 // MAIN 618 // ============================================================================= 619 620 async function main() { 621 renderFeed(); 622 renderLoadMore(); 623 await loadPlays(); 624 connectLive(); 625 } 626 627 function connectLive() { 628 const wsUrl = SERVER_URL.replace("https://", "wss://") + "/graphql"; 629 let ws; 630 let retries = 0; 631 const maxRetries = 5; 632 633 function connect() { 634 ws = new WebSocket(wsUrl, "graphql-transport-ws"); 635 636 ws.onopen = () => { 637 retries = 0; 638 ws.send(JSON.stringify({ type: "connection_init" })); 639 }; 640 641 ws.onmessage = (e) => { 642 const msg = JSON.parse(e.data); 643 644 if (msg.type === "connection_ack") { 645 ws.send(JSON.stringify({ id: "1", type: "subscribe", payload: { query: PLAY_SUB } })); 646 state.liveConnected = true; 647 updateLiveDot(); 648 } 649 650 if (msg.type === "next" && msg.payload?.data?.fmTealAlphaFeedPlayCreated) { 651 handleNewPlay(msg.payload.data.fmTealAlphaFeedPlayCreated); 652 } 653 }; 654 655 ws.onclose = () => { 656 state.liveConnected = false; 657 updateLiveDot(); 658 659 if (retries < maxRetries) { 660 retries++; 661 setTimeout(connect, 3000); 662 } 663 }; 664 665 ws.onerror = () => ws.close(); 666 } 667 668 connect(); 669 } 670 671 function updateLiveDot() { 672 const dot = document.getElementById("live-dot"); 673 dot.classList.toggle("off", !state.liveConnected); 674 dot.title = state.liveConnected ? "Live updates active" : "Reconnecting..."; 675 } 676 677 function handleNewPlay(play) { 678 // Skip duplicates 679 if (state.plays.some((p) => p.uri === play.uri)) return; 680 681 // Insert in correct position by playedTime (descending) 682 const playTime = new Date(play.playedTime).getTime(); 683 const insertIdx = state.plays.findIndex((p) => new Date(p.playedTime).getTime() < playTime); 684 if (insertIdx === -1) { 685 state.plays.push(play); 686 } else { 687 state.plays.splice(insertIdx, 0, play); 688 } 689 690 renderFeedWithHighlight(play.uri); 691 } 692 693 function renderFeedWithHighlight(highlightUri) { 694 const el = document.getElementById("play-feed"); 695 el.innerHTML = state.plays.map((p) => renderPlayCard(p, p.uri === highlightUri)).join(""); 696 } 697 698 function renderLoadMore() { 699 const el = document.getElementById("load-more"); 700 701 if (state.plays.length === 0) { 702 el.innerHTML = ""; 703 return; 704 } 705 706 if (!state.hasMore) { 707 el.innerHTML = `<div class="status-msg">No more plays</div>`; 708 return; 709 } 710 711 el.innerHTML = ` 712 <div class="load-more"> 713 <button class="btn btn-primary" onclick="handleLoadMore()" ${state.isLoading ? "disabled" : ""}> 714 ${state.isLoading ? '<span class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></span>' : "Load More"} 715 </button> 716 </div> 717 `; 718 } 719 720 function handleLoadMore() { 721 loadPlays(true); 722 } 723 724 function renderFeed() { 725 const el = document.getElementById("play-feed"); 726 727 if (state.isLoading && state.plays.length === 0) { 728 el.innerHTML = `<div class="loading-container"><div class="spinner"></div><span>Loading plays...</span></div>`; 729 return; 730 } 731 732 if (state.plays.length === 0) { 733 el.innerHTML = `<div class="status-msg">No plays yet.</div>`; 734 return; 735 } 736 737 el.innerHTML = state.plays.map((p) => renderPlayCard(p)).join(""); 738 } 739 740 async function loadPlays(append = false) { 741 if (state.isLoading) return; 742 state.isLoading = true; 743 renderFeed(); 744 renderLoadMore(); 745 746 try { 747 const data = await fetchPlays(append ? state.cursor : null); 748 const newPlays = data.edges.map((e) => e.node); 749 750 state.plays = append ? [...state.plays, ...newPlays] : newPlays; 751 state.cursor = data.pageInfo.endCursor; 752 state.hasMore = data.pageInfo.hasNextPage; 753 754 renderFeed(); 755 } catch (err) { 756 console.error("Load failed:", err); 757 showError(`Failed to load: ${err.message}`); 758 } finally { 759 state.isLoading = false; 760 renderLoadMore(); 761 } 762 } 763 764 main(); 765 </script> 766 </body> 767</html>