audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(frontend): add /embed/track/[id] route for external players (#330)

authored by

nate nowack and committed by
GitHub
6e0f41ac 63fee328

+297
+23
frontend/src/routes/embed/track/[id]/+page.server.ts
··· 1 + import { API_URL } from '$lib/config'; 2 + import type { Track } from '$lib/types'; 3 + import { error } from '@sveltejs/kit'; 4 + import type { PageServerLoad } from './$types'; 5 + 6 + export const load: PageServerLoad = async ({ params, fetch }) => { 7 + try { 8 + const response = await fetch(`${API_URL}/tracks/${params.id}`); 9 + 10 + if (!response.ok) { 11 + throw error(404, 'track not found'); 12 + } 13 + 14 + const track: Track = await response.json(); 15 + 16 + return { 17 + track 18 + }; 19 + } catch (e) { 20 + console.error('failed to load track:', e); 21 + throw error(404, 'track not found'); 22 + } 23 + };
+274
frontend/src/routes/embed/track/[id]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/stores'; 3 + import { onMount } from 'svelte'; 4 + import type { PageData } from './$types'; 5 + 6 + let { data }: { data: PageData } = $props(); 7 + let track = $derived(data.track); 8 + 9 + let audio: HTMLAudioElement; 10 + let paused = $state(true); 11 + let currentTime = $state(0); 12 + let duration = $state(0); 13 + 14 + function togglePlay() { 15 + if (audio.paused) { 16 + audio.play(); 17 + } else { 18 + audio.pause(); 19 + } 20 + } 21 + 22 + function formatTime(seconds: number) { 23 + const m = Math.floor(seconds / 60); 24 + const s = Math.floor(seconds % 60); 25 + return `${m}:${s.toString().padStart(2, '0')}`; 26 + } 27 + 28 + function handleSeek(e: MouseEvent) { 29 + const bar = e.currentTarget as HTMLElement; 30 + const rect = bar.getBoundingClientRect(); 31 + const x = e.clientX - rect.left; 32 + const pct = x / rect.width; 33 + audio.currentTime = pct * duration; 34 + } 35 + 36 + onMount(() => { 37 + const autoplay = $page.url.searchParams.get('autoplay') === '1'; 38 + if (autoplay) { 39 + audio.play().catch(() => { 40 + // Autoplay policy might block this 41 + paused = true; 42 + }); 43 + } 44 + }); 45 + </script> 46 + 47 + <div class="embed-container"> 48 + <div class="art-container"> 49 + {#if track.image_url} 50 + <img src={track.image_url} alt={track.title} class="art" /> 51 + {:else} 52 + <div class="art-placeholder">♪</div> 53 + {/if} 54 + </div> 55 + 56 + <div class="content"> 57 + <div class="header"> 58 + <button class="play-btn" onclick={togglePlay} aria-label={paused ? 'Play' : 'Pause'}> 59 + {#if paused} 60 + <svg viewBox="0 0 24 24" fill="currentColor" class="icon"> 61 + <path d="M8 5v14l11-7z" /> 62 + </svg> 63 + {:else} 64 + <svg viewBox="0 0 24 24" fill="currentColor" class="icon"> 65 + <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 66 + </svg> 67 + {/if} 68 + </button> 69 + 70 + <div class="meta"> 71 + <a href="https://plyr.fm/track/{track.id}" target="_blank" rel="noopener noreferrer" class="title"> 72 + {track.title} 73 + </a> 74 + <div class="artist">{track.artist}</div> 75 + </div> 76 + 77 + <a href="https://plyr.fm" target="_blank" rel="noopener noreferrer" class="logo"> 78 + plyr.fm 79 + </a> 80 + </div> 81 + 82 + <div class="player-controls"> 83 + <div class="time">{formatTime(currentTime)}</div> 84 + <!-- svelte-ignore a11y_click_events_have_key_events --> 85 + <!-- svelte-ignore a11y_no_static_element_interactions --> 86 + <div class="progress-bar" onclick={handleSeek}> 87 + <div class="progress-bg"></div> 88 + <div class="progress-fill" style="width: {(currentTime / (duration || 1)) * 100}%"></div> 89 + </div> 90 + <div class="time">{formatTime(duration)}</div> 91 + </div> 92 + </div> 93 + 94 + <audio 95 + bind:this={audio} 96 + src={track.r2_url} 97 + bind:paused 98 + bind:currentTime 99 + bind:duration 100 + onended={() => (paused = true)} 101 + ></audio> 102 + </div> 103 + 104 + <style> 105 + :global(body) { 106 + margin: 0; 107 + padding: 0; 108 + overflow: hidden; 109 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 110 + background: #000; 111 + color: #fff; 112 + } 113 + 114 + .embed-container { 115 + display: flex; 116 + height: 165px; 117 + background: #1a1a1a; 118 + overflow: hidden; 119 + } 120 + 121 + .art-container { 122 + width: 165px; 123 + height: 165px; 124 + flex-shrink: 0; 125 + position: relative; 126 + } 127 + 128 + .art { 129 + width: 100%; 130 + height: 100%; 131 + object-fit: cover; 132 + } 133 + 134 + .art-placeholder { 135 + width: 100%; 136 + height: 100%; 137 + background: #333; 138 + display: flex; 139 + align-items: center; 140 + justify-content: center; 141 + font-size: 48px; 142 + color: #555; 143 + } 144 + 145 + .content { 146 + flex: 1; 147 + padding: 16px; 148 + display: flex; 149 + flex-direction: column; 150 + justify-content: space-between; 151 + position: relative; 152 + } 153 + 154 + .header { 155 + display: flex; 156 + align-items: flex-start; 157 + gap: 16px; 158 + } 159 + 160 + .play-btn { 161 + width: 48px; 162 + height: 48px; 163 + border-radius: 50%; 164 + background: #fff; 165 + color: #000; 166 + border: none; 167 + display: flex; 168 + align-items: center; 169 + justify-content: center; 170 + cursor: pointer; 171 + flex-shrink: 0; 172 + transition: transform 0.1s; 173 + } 174 + 175 + .play-btn:active { 176 + transform: scale(0.95); 177 + } 178 + 179 + .icon { 180 + width: 24px; 181 + height: 24px; 182 + } 183 + 184 + .meta { 185 + flex: 1; 186 + min-width: 0; 187 + padding-top: 4px; 188 + } 189 + 190 + .title { 191 + display: block; 192 + font-size: 18px; 193 + font-weight: 700; 194 + margin: 0 0 4px; 195 + white-space: nowrap; 196 + overflow: hidden; 197 + text-overflow: ellipsis; 198 + text-decoration: none; 199 + color: #fff; 200 + } 201 + 202 + .title:hover { 203 + text-decoration: underline; 204 + } 205 + 206 + .artist { 207 + font-size: 14px; 208 + color: #aaa; 209 + white-space: nowrap; 210 + overflow: hidden; 211 + text-overflow: ellipsis; 212 + } 213 + 214 + .logo { 215 + position: absolute; 216 + top: 16px; 217 + right: 16px; 218 + font-size: 12px; 219 + font-weight: 700; 220 + color: #444; 221 + text-decoration: none; 222 + text-transform: uppercase; 223 + letter-spacing: 1px; 224 + } 225 + 226 + .logo:hover { 227 + color: #666; 228 + } 229 + 230 + .player-controls { 231 + display: flex; 232 + align-items: center; 233 + gap: 12px; 234 + margin-bottom: 4px; 235 + } 236 + 237 + .time { 238 + font-size: 12px; 239 + color: #777; 240 + font-variant-numeric: tabular-nums; 241 + width: 35px; 242 + text-align: center; 243 + } 244 + 245 + .progress-bar { 246 + flex: 1; 247 + height: 24px; /* larger hit area */ 248 + display: flex; 249 + align-items: center; 250 + cursor: pointer; 251 + position: relative; 252 + } 253 + 254 + .progress-bg { 255 + width: 100%; 256 + height: 4px; 257 + background: #333; 258 + border-radius: 2px; 259 + } 260 + 261 + .progress-fill { 262 + position: absolute; 263 + left: 0; 264 + top: 10px; /* (24 - 4) / 2 */ 265 + height: 4px; 266 + background: #fff; 267 + border-radius: 2px; 268 + pointer-events: none; 269 + } 270 + 271 + .progress-bar:hover .progress-fill { 272 + background: #3b82f6; /* blue-500 */ 273 + } 274 + </style>