Tools for the Atmosphere
tools.slices.network
quickslice
atproto
html
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <meta
7 http-equiv="Content-Security-Policy"
8 content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; connect-src 'self' https://fmteal.slices.network 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>