audio streaming app plyr.fm

docs: favicon, tangled link, interactive landing page (#1033)

* docs: favicon, tangled link, interactive landing page

- replace emoji favicon with actual plyr.fm logo
- swap GitHub social icon for tangled.org repo link
- add live stats cards, track search, ATProto record example to landing page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: use api.plyr.fm instead of relay-api.fly.dev

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
94cfc90d 46a42ef3

+358 -26
+4 -8
docs-site/astro.config.mjs
··· 7 7 integrations: [ 8 8 starlight({ 9 9 title: "plyr.fm docs", 10 - favicon: "/favicon.svg", 11 - social: [ 12 - { 13 - icon: "github", 14 - label: "GitHub", 15 - href: "https://github.com/zzstoatzz/plyr.fm", 16 - }, 17 - ], 10 + favicon: "/favicon.png", 11 + components: { 12 + SocialIcons: "./src/components/SocialIcons.astro", 13 + }, 18 14 plugins: [starlightClientMermaid()], 19 15 sidebar: [ 20 16 {
docs-site/public/favicon.png

This is a binary file and will not be displayed.

-3
docs-site/public/favicon.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"> 2 - <text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="28">🎵</text> 3 - </svg>
+48
docs-site/src/components/SocialIcons.astro
··· 1 + --- 2 + // replaces Starlight's default SocialIcons with a single link to the tangled repo 3 + --- 4 + 5 + <div class="social-icons"> 6 + <a 7 + href="https://tangled.org/zzstoatzz.io/plyr.fm" 8 + aria-label="tangled.org" 9 + target="_blank" 10 + rel="noopener" 11 + class="tangled-link" 12 + > 13 + <svg 14 + xmlns="http://www.w3.org/2000/svg" 15 + width="16" 16 + height="16" 17 + viewBox="0 0 24 24" 18 + fill="none" 19 + stroke="currentColor" 20 + stroke-width="2" 21 + stroke-linecap="round" 22 + stroke-linejoin="round" 23 + aria-hidden="true" 24 + > 25 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> 26 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> 27 + </svg> 28 + </a> 29 + </div> 30 + 31 + <style> 32 + .social-icons { 33 + display: flex; 34 + align-items: center; 35 + gap: 0.5rem; 36 + } 37 + 38 + .tangled-link { 39 + color: var(--sl-color-gray-1); 40 + transition: color 0.2s ease; 41 + display: flex; 42 + align-items: center; 43 + } 44 + 45 + .tangled-link:hover { 46 + color: var(--sl-color-white); 47 + } 48 + </style>
+67
docs-site/src/components/Stats.astro
··· 1 + --- 2 + // live platform stats fetched client-side from the public /stats endpoint 3 + --- 4 + 5 + <div class="stats-section" id="stats-section"> 6 + <div class="stats-grid"> 7 + <div class="stat-card"> 8 + <span class="stat-value" id="stat-tracks">&mdash;</span> 9 + <span class="stat-label">tracks</span> 10 + </div> 11 + <div class="stat-card"> 12 + <span class="stat-value" id="stat-artists">&mdash;</span> 13 + <span class="stat-label">artists</span> 14 + </div> 15 + <div class="stat-card"> 16 + <span class="stat-value" id="stat-plays">&mdash;</span> 17 + <span class="stat-label">plays</span> 18 + </div> 19 + <div class="stat-card"> 20 + <span class="stat-value" id="stat-hours">&mdash;</span> 21 + <span class="stat-label">hours of audio</span> 22 + </div> 23 + </div> 24 + </div> 25 + 26 + <script> 27 + const API = "https://api.plyr.fm"; 28 + 29 + async function loadStats() { 30 + try { 31 + const res = await fetch(`${API}/stats`); 32 + if (!res.ok) return; 33 + const data = await res.json(); 34 + 35 + const fmt = (n: number) => 36 + n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); 37 + 38 + document.getElementById("stat-tracks")!.textContent = fmt( 39 + data.total_tracks, 40 + ); 41 + document.getElementById("stat-artists")!.textContent = fmt( 42 + data.total_artists, 43 + ); 44 + document.getElementById("stat-plays")!.textContent = fmt( 45 + data.total_plays, 46 + ); 47 + document.getElementById("stat-hours")!.textContent = fmt( 48 + Math.round(data.total_duration_seconds / 3600), 49 + ); 50 + } catch { 51 + // stats are non-critical — leave dashes 52 + } 53 + } 54 + 55 + const observer = new IntersectionObserver( 56 + (entries) => { 57 + if (entries[0].isIntersecting) { 58 + loadStats(); 59 + observer.disconnect(); 60 + } 61 + }, 62 + { threshold: 0.1 }, 63 + ); 64 + 65 + const el = document.getElementById("stats-section"); 66 + if (el) observer.observe(el); 67 + </script>
+75
docs-site/src/components/TrackSearch.astro
··· 1 + --- 2 + // interactive track search — debounced client-side fetch from /search/ 3 + --- 4 + 5 + <div class="search-section" id="search-section"> 6 + <input 7 + type="text" 8 + id="track-search-input" 9 + placeholder="search tracks..." 10 + autocomplete="off" 11 + /> 12 + <div id="search-results" class="search-results"></div> 13 + </div> 14 + 15 + <script> 16 + const API = "https://api.plyr.fm"; 17 + const input = document.getElementById( 18 + "track-search-input", 19 + ) as HTMLInputElement; 20 + const resultsEl = document.getElementById("search-results")!; 21 + let timeout: ReturnType<typeof setTimeout>; 22 + 23 + function escapeHtml(s: string) { 24 + const div = document.createElement("div"); 25 + div.textContent = s; 26 + return div.innerHTML; 27 + } 28 + 29 + async function search(q: string) { 30 + if (q.length < 2) { 31 + resultsEl.innerHTML = ""; 32 + return; 33 + } 34 + 35 + try { 36 + const res = await fetch( 37 + `${API}/search/?q=${encodeURIComponent(q)}&type=tracks&limit=5`, 38 + ); 39 + if (!res.ok) { 40 + resultsEl.innerHTML = ""; 41 + return; 42 + } 43 + const data = await res.json(); 44 + const tracks = data.results.filter( 45 + (r: { type: string }) => r.type === "track", 46 + ); 47 + 48 + if (tracks.length === 0) { 49 + resultsEl.innerHTML = '<div class="search-empty">no results</div>'; 50 + return; 51 + } 52 + 53 + resultsEl.innerHTML = tracks 54 + .map( 55 + (t: { 56 + title: string; 57 + artist_display_name: string; 58 + artist_handle: string; 59 + }) => 60 + `<div class="search-result"> 61 + <span class="search-title">${escapeHtml(t.title)}</span> 62 + <span class="search-artist">${escapeHtml(t.artist_display_name || t.artist_handle)}</span> 63 + </div>`, 64 + ) 65 + .join(""); 66 + } catch { 67 + resultsEl.innerHTML = ""; 68 + } 69 + } 70 + 71 + input.addEventListener("input", () => { 72 + clearTimeout(timeout); 73 + timeout = setTimeout(() => search(input.value.trim()), 300); 74 + }); 75 + </script>
+108
docs-site/src/styles/custom.css
··· 41 41 --sl-color-gray-6: #f5f5f5; 42 42 --sl-color-black: #ffffff; 43 43 } 44 + 45 + /* landing page sections */ 46 + .landing-section { 47 + max-width: 48rem; 48 + margin: 3rem auto; 49 + padding: 0 1rem; 50 + } 51 + 52 + .landing-section h2 { 53 + font-size: 1.1rem; 54 + color: var(--sl-color-gray-2); 55 + text-transform: lowercase; 56 + letter-spacing: 0.05em; 57 + margin-bottom: 1rem; 58 + } 59 + 60 + /* stats cards */ 61 + .stats-grid { 62 + display: grid; 63 + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); 64 + gap: 1rem; 65 + } 66 + 67 + .stat-card { 68 + display: flex; 69 + flex-direction: column; 70 + align-items: center; 71 + padding: 1.25rem 1rem; 72 + border: 1px solid var(--sl-color-gray-4); 73 + border-radius: 0.5rem; 74 + background: var(--sl-color-gray-6); 75 + } 76 + 77 + .stat-value { 78 + font-size: 1.75rem; 79 + font-weight: 700; 80 + color: var(--sl-color-accent); 81 + } 82 + 83 + .stat-label { 84 + font-size: 0.8rem; 85 + color: var(--sl-color-gray-2); 86 + margin-top: 0.25rem; 87 + } 88 + 89 + /* track search */ 90 + .search-section { 91 + max-width: 32rem; 92 + } 93 + 94 + #track-search-input { 95 + width: 100%; 96 + padding: 0.6rem 0.8rem; 97 + font-family: var(--sl-font); 98 + font-size: 0.9rem; 99 + color: var(--sl-color-white); 100 + background: var(--sl-color-gray-6); 101 + border: 1px solid var(--sl-color-gray-4); 102 + border-radius: 0.375rem; 103 + outline: none; 104 + transition: border-color 0.2s ease; 105 + } 106 + 107 + #track-search-input:focus { 108 + border-color: var(--sl-color-accent); 109 + } 110 + 111 + #track-search-input::placeholder { 112 + color: var(--sl-color-gray-3); 113 + } 114 + 115 + .search-results { 116 + margin-top: 0.5rem; 117 + } 118 + 119 + .search-result { 120 + display: flex; 121 + justify-content: space-between; 122 + align-items: baseline; 123 + padding: 0.5rem 0.8rem; 124 + border-bottom: 1px solid var(--sl-color-gray-5); 125 + } 126 + 127 + .search-result:last-child { 128 + border-bottom: none; 129 + } 130 + 131 + .search-title { 132 + color: var(--sl-color-white); 133 + font-size: 0.9rem; 134 + } 135 + 136 + .search-artist { 137 + color: var(--sl-color-gray-2); 138 + font-size: 0.8rem; 139 + } 140 + 141 + .search-empty { 142 + color: var(--sl-color-gray-3); 143 + font-size: 0.85rem; 144 + padding: 0.5rem 0.8rem; 145 + } 146 + 147 + /* ATProto record block */ 148 + .record-example pre { 149 + border: 1px solid var(--sl-color-gray-4); 150 + border-radius: 0.5rem; 151 + }
-15
docs/index.md
··· 1 - --- 2 - title: plyr.fm docs 3 - description: documentation for plyr.fm — audio streaming on ATProto 4 - template: splash 5 - hero: 6 - title: plyr.fm docs 7 - tagline: audio streaming on ATProto 8 - actions: 9 - - text: get started 10 - link: /local-development/setup/ 11 - icon: right-arrow 12 - - text: contributing 13 - link: /contributing/ 14 - variant: minimal 15 - ---
+56
docs/index.mdx
··· 1 + --- 2 + title: plyr.fm docs 3 + description: documentation for plyr.fm — audio streaming on ATProto 4 + template: splash 5 + hero: 6 + title: plyr.fm docs 7 + tagline: audio streaming on ATProto 8 + actions: 9 + - text: get started 10 + link: /local-development/setup/ 11 + icon: right-arrow 12 + - text: contributing 13 + link: /contributing/ 14 + variant: minimal 15 + --- 16 + 17 + import Stats from '../../components/Stats.astro'; 18 + import TrackSearch from '../../components/TrackSearch.astro'; 19 + 20 + <div class="landing-section"> 21 + <h2>platform stats</h2> 22 + <Stats /> 23 + </div> 24 + 25 + <div class="landing-section"> 26 + <h2>try it — search tracks</h2> 27 + <TrackSearch /> 28 + </div> 29 + 30 + <div class="landing-section"> 31 + <h2>your music, your data</h2> 32 + <p style="color: var(--sl-color-gray-2); font-size: 0.9rem; margin-bottom: 1rem;"> 33 + every track on plyr.fm is an ATProto record in the artist's personal data repo. here's what one looks like: 34 + </p> 35 + <div class="record-example"> 36 + 37 + ```json 38 + { 39 + "$type": "fm.plyr.track", 40 + "title": "late night drive", 41 + "createdAt": "2026-02-14T03:22:00.000Z", 42 + "audio": { 43 + "$type": "blob", 44 + "ref": { "$link": "bafkrei..." }, 45 + "mimeType": "audio/mpeg", 46 + "size": 8420196 47 + }, 48 + "tags": ["electronic", "ambient"] 49 + } 50 + ``` 51 + 52 + </div> 53 + <p style="color: var(--sl-color-gray-3); font-size: 0.8rem; margin-top: 0.75rem;"> 54 + the API is public. build your own player, analytics, or recommendation engine — the data is open. 55 + </p> 56 + </div>