music on atproto
plyr.fm
1<script lang="ts">
2 import logo from '$lib/assets/logo.png';
3 import {
4 APP_NAME,
5 APP_TAGLINE,
6 APP_CANONICAL_URL
7 } from '$lib/branding';
8 import Player from '$lib/components/Player.svelte';
9 import Toast from '$lib/components/Toast.svelte';
10 import Queue from '$lib/components/Queue.svelte';
11 import SearchModal from '$lib/components/SearchModal.svelte';
12 import LogoutModal from '$lib/components/LogoutModal.svelte';
13 import { onMount, onDestroy } from 'svelte';
14 import { page } from '$app/stores';
15 import { afterNavigate } from '$app/navigation';
16 import { auth } from '$lib/auth.svelte';
17 import { preferences } from '$lib/preferences.svelte';
18 import { moderation } from '$lib/moderation.svelte';
19 import { player } from '$lib/player.svelte';
20 import { queue } from '$lib/queue.svelte';
21 import { search } from '$lib/search.svelte';
22 import { browser } from '$app/environment';
23 import type { LayoutData } from './$types';
24
25 let { children, data } = $props<{ children: any; data: LayoutData }>();
26 let showQueue = $state(false);
27
28 // pages that define their own <title> in svelte:head
29 let hasPageMetadata = $derived(
30 $page.url.pathname === '/' || // homepage
31 $page.url.pathname.startsWith('/track/') || // track detail
32 $page.url.pathname.startsWith('/playlist/') || // playlist detail
33 $page.url.pathname.startsWith('/tag/') || // tag detail
34 $page.url.pathname === '/liked' || // liked tracks
35 $page.url.pathname.match(/^\/u\/[^/]+$/) || // artist detail
36 $page.url.pathname.match(/^\/u\/[^/]+\/album\/[^/]+/) // album detail
37 );
38
39 let isEmbed = $derived($page.url.pathname.startsWith('/embed/'));
40
41 // sync auth and preferences state from layout data (fetched by +layout.ts)
42 $effect(() => {
43 if (browser) {
44 auth.user = data.user;
45 auth.isAuthenticated = data.isAuthenticated;
46 auth.loading = false;
47 preferences.data = data.preferences;
48 // fetch explicit images list (public, no auth needed)
49 moderation.initialize();
50 if (data.isAuthenticated && queue.revision === null) {
51 void queue.fetchQueue();
52 }
53 }
54 });
55
56 // document title: show playing track, or fall back to page title
57 let pageTitle = $state(`${APP_NAME} - ${APP_TAGLINE}`);
58
59 function updateTitle() {
60 const track = player.currentTrack;
61 const playing = track && !player.paused;
62 document.title = playing
63 ? `${track.title} - ${track.artist} • ${APP_NAME}`
64 : pageTitle;
65 }
66
67 afterNavigate(() => {
68 // capture page title after svelte:head renders, then apply correct title
69 window.requestAnimationFrame(() => {
70 const currentTitle = document.title;
71 if (!currentTitle.includes(` • ${APP_NAME}`)) {
72 pageTitle = currentTitle;
73 }
74 updateTitle();
75 });
76 });
77
78 // react to play/pause changes
79 $effect(() => {
80 if (!browser) return;
81 player.currentTrack;
82 player.paused;
83 updateTitle();
84 });
85
86 // set CSS custom property for queue width adjustment
87 $effect(() => {
88 if (!browser) return;
89 const queueWidth = showQueue && !isEmbed ? '360px' : '0px';
90 document.documentElement.style.setProperty('--queue-width', queueWidth);
91 });
92
93 // apply background image from ui_settings or playing track artwork
94 // only apply when preferences are actually loaded (not null) to avoid clearing on initial load
95 $effect(() => {
96 if (!browser) return;
97 // don't clear bg image if preferences haven't loaded yet
98 if (!preferences.loaded) return;
99
100 const uiSettings = preferences.uiSettings;
101 const root = document.documentElement;
102
103 // determine background image URL
104 // priority: playing artwork (if enabled and available) > custom URL
105 let bgImageUrl: string | undefined;
106 let isUsingPlayingArtwork = false;
107 if (uiSettings.use_playing_artwork_as_background && player.currentTrack?.image_url) {
108 bgImageUrl = player.currentTrack.image_url;
109 isUsingPlayingArtwork = true;
110 } else if (uiSettings.background_image_url) {
111 // fall back to custom URL (whether playing artwork is enabled or not)
112 bgImageUrl = uiSettings.background_image_url;
113 }
114
115 if (bgImageUrl) {
116 root.style.setProperty('--bg-image', `url(${bgImageUrl})`);
117 // playing artwork tiles in a 4x4 grid with blur, custom image respects tile setting
118 const shouldTile = isUsingPlayingArtwork || uiSettings.background_tile;
119 root.style.setProperty('--bg-image-mode', shouldTile ? 'repeat' : 'no-repeat');
120 // playing artwork: 25% size (4x4 grid), custom: auto if tiled, cover if not
121 root.style.setProperty('--bg-image-size', isUsingPlayingArtwork ? '25%' : (uiSettings.background_tile ? 'auto' : 'cover'));
122 // blur playing artwork for smoother look
123 root.style.setProperty('--bg-blur', isUsingPlayingArtwork ? '40px' : '0px');
124 // glass button styling for visibility against background images
125 const isLight = root.classList.contains('theme-light');
126 root.style.setProperty('--glass-btn-bg', isLight ? 'rgba(255, 255, 255, 0.8)' : 'rgba(18, 18, 18, 0.8)');
127 root.style.setProperty('--glass-btn-bg-hover', isLight ? 'rgba(255, 255, 255, 0.9)' : 'rgba(30, 30, 30, 0.9)');
128 root.style.setProperty('--glass-btn-border', isLight ? 'rgba(0, 0, 0, 0.12)' : 'rgba(255, 255, 255, 0.12)');
129 // very subtle text outline for readability against background images
130 root.style.setProperty('--text-shadow', isLight ? '0 0 8px rgba(255, 255, 255, 0.6)' : '0 0 8px rgba(0, 0, 0, 0.6)');
131 } else {
132 root.style.removeProperty('--bg-image');
133 root.style.removeProperty('--bg-image-mode');
134 root.style.removeProperty('--bg-image-size');
135 root.style.removeProperty('--bg-blur');
136 root.style.removeProperty('--glass-btn-bg');
137 root.style.removeProperty('--glass-btn-bg-hover');
138 root.style.removeProperty('--glass-btn-border');
139 root.style.removeProperty('--text-shadow');
140 }
141 });
142
143 const SEEK_AMOUNT = 10; // seconds
144 let previousVolume = 0.7; // for mute toggle
145
146 function handleKeyboardShortcuts(event: KeyboardEvent) {
147 // Cmd/Ctrl+K: toggle search
148 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
149 event.preventDefault();
150 search.toggle();
151 return;
152 }
153
154 // ignore other modifier keys for remaining shortcuts
155 if (event.metaKey || event.ctrlKey || event.altKey) {
156 return;
157 }
158
159 // ignore if inside input/textarea/contenteditable
160 const target = event.target as HTMLElement;
161 if (
162 target.tagName === 'INPUT' ||
163 target.tagName === 'TEXTAREA' ||
164 target.isContentEditable
165 ) {
166 return;
167 }
168
169 // ignore playback shortcuts when search modal is open
170 if (search.isOpen) {
171 return;
172 }
173
174 const key = event.key.toLowerCase();
175
176 // toggle queue on 'q' key
177 if (key === 'q') {
178 event.preventDefault();
179 toggleQueue();
180 return;
181 }
182
183 // playback shortcuts - only when a track is loaded
184 if (!player.currentTrack) {
185 return;
186 }
187
188 switch (event.key) {
189 case ' ': // space - play/pause
190 event.preventDefault();
191 player.togglePlayPause();
192 break;
193
194 case 'ArrowLeft': // seek backward
195 event.preventDefault();
196 seekBy(-SEEK_AMOUNT);
197 break;
198
199 case 'ArrowRight': // seek forward
200 event.preventDefault();
201 seekBy(SEEK_AMOUNT);
202 break;
203
204 case 'j': // previous track (youtube-style)
205 case 'J':
206 event.preventDefault();
207 handlePreviousTrack();
208 break;
209
210 case 'l': // next track (youtube-style)
211 case 'L':
212 event.preventDefault();
213 if (queue.hasNext) {
214 queue.next();
215 }
216 break;
217
218 case 'm': // mute/unmute
219 case 'M':
220 event.preventDefault();
221 toggleMute();
222 break;
223 }
224 }
225
226 function seekBy(seconds: number) {
227 if (!player.audioElement || !player.duration) return;
228
229 const newTime = Math.max(0, Math.min(player.duration, player.currentTime + seconds));
230 player.currentTime = newTime;
231 player.audioElement.currentTime = newTime;
232 }
233
234 function handlePreviousTrack() {
235 const RESTART_THRESHOLD = 3; // restart if more than 3 seconds in
236
237 if (player.currentTime > RESTART_THRESHOLD) {
238 // restart current track
239 player.currentTime = 0;
240 if (player.audioElement) {
241 player.audioElement.currentTime = 0;
242 }
243 } else if (queue.hasPrevious) {
244 // go to previous track
245 queue.previous();
246 } else {
247 // restart from beginning
248 player.currentTime = 0;
249 if (player.audioElement) {
250 player.audioElement.currentTime = 0;
251 }
252 }
253 }
254
255 function toggleMute() {
256 if (player.volume > 0) {
257 previousVolume = player.volume;
258 player.volume = 0;
259 } else {
260 player.volume = previousVolume || 0.7;
261 }
262 }
263
264 onMount(() => {
265 // apply saved accent color from localStorage
266 const savedAccent = localStorage.getItem('accentColor');
267 if (savedAccent) {
268 document.documentElement.style.setProperty('--accent', savedAccent);
269 document.documentElement.style.setProperty('--accent-hover', getHoverColor(savedAccent));
270 }
271
272 // apply saved theme from localStorage
273 const savedTheme = localStorage.getItem('theme') as 'dark' | 'light' | 'system' | null;
274 if (savedTheme) {
275 preferences.applyTheme(savedTheme);
276 } else {
277 // default to dark
278 document.documentElement.classList.add('theme-dark');
279 }
280
281 // restore queue visibility preference
282 const savedQueueVisibility = localStorage.getItem('showQueue');
283 if (savedQueueVisibility !== null) {
284 showQueue = savedQueueVisibility === 'true';
285 }
286
287 // add keyboard listener for shortcuts
288 window.addEventListener('keydown', handleKeyboardShortcuts);
289
290 // listen for system theme changes
291 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
292 const handleSystemThemeChange = () => {
293 const currentTheme = localStorage.getItem('theme');
294 if (currentTheme === 'system') {
295 preferences.applyTheme('system');
296 }
297 };
298 mediaQuery.addEventListener('change', handleSystemThemeChange);
299
300 return () => {
301 mediaQuery.removeEventListener('change', handleSystemThemeChange);
302 };
303 });
304
305 onDestroy(() => {
306 // cleanup keyboard listener
307 if (browser) {
308 window.removeEventListener('keydown', handleKeyboardShortcuts);
309 }
310 });
311
312 function getHoverColor(hex: string): string {
313 // lighten the accent color by mixing with white
314 const r = parseInt(hex.slice(1, 3), 16);
315 const g = parseInt(hex.slice(3, 5), 16);
316 const b = parseInt(hex.slice(5, 7), 16);
317 return `rgb(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)})`;
318 }
319
320 function toggleQueue() {
321 showQueue = !showQueue;
322 localStorage.setItem('showQueue', showQueue.toString());
323 }
324</script>
325
326<svelte:head>
327 <link rel="icon" href={logo} />
328 <link rel="manifest" href="/manifest.webmanifest" />
329 <meta name="theme-color" content="#0a0a0a" />
330
331 {#if !hasPageMetadata}
332 <!-- default meta tags for pages without specific metadata -->
333 <title>{APP_NAME} - {APP_TAGLINE}</title>
334 <meta
335 name="description"
336 content={`discover and stream audio on the AT Protocol with ${APP_NAME}`}
337 />
338
339 <!-- Open Graph / Facebook -->
340 <meta property="og:type" content="website" />
341 <meta property="og:title" content="{APP_NAME} - {APP_TAGLINE}" />
342 <meta
343 property="og:description"
344 content={`discover and stream audio on the AT Protocol with ${APP_NAME}`}
345 />
346 <meta property="og:site_name" content={APP_NAME} />
347 <meta property="og:url" content={APP_CANONICAL_URL} />
348 <meta property="og:image" content={logo} />
349
350 <!-- Twitter -->
351 <meta name="twitter:card" content="summary" />
352 <meta name="twitter:title" content="{APP_NAME} - {APP_TAGLINE}" />
353 <meta
354 name="twitter:description"
355 content={`discover and stream audio on the AT Protocol with ${APP_NAME}`}
356 />
357 <meta name="twitter:image" content={logo} />
358 {/if}
359
360 <script>
361 // prevent flash by applying saved settings immediately
362 if (typeof window !== 'undefined') {
363 (function() {
364 const root = document.documentElement;
365
366 // apply accent color
367 const savedAccent = localStorage.getItem('accentColor');
368 if (savedAccent) {
369 root.style.setProperty('--accent', savedAccent);
370 // simple lightening for hover state
371 const r = parseInt(savedAccent.slice(1, 3), 16);
372 const g = parseInt(savedAccent.slice(3, 5), 16);
373 const b = parseInt(savedAccent.slice(5, 7), 16);
374 const hover = `rgb(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)})`;
375 root.style.setProperty('--accent-hover', hover);
376 }
377
378 // apply theme
379 const savedTheme = localStorage.getItem('theme') || 'dark';
380 let effectiveTheme = savedTheme;
381 if (savedTheme === 'system') {
382 effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
383 }
384 root.classList.add('theme-' + effectiveTheme);
385 })();
386 }
387 </script>
388</svelte:head>
389
390<div class="app-layout">
391 <main class="main-content" class:with-queue={showQueue && !isEmbed}>
392 {@render children?.()}
393 </main>
394
395 {#if showQueue && !isEmbed}
396 <aside class="queue-sidebar">
397 <Queue />
398 </aside>
399 {/if}
400</div>
401
402{#if !isEmbed}
403 <button
404 class="queue-toggle"
405 onclick={toggleQueue}
406 aria-pressed={showQueue}
407 aria-label="toggle queue (Q)"
408 title={showQueue ? 'hide queue (Q)' : 'show queue (Q)'}
409 >
410 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
411 <line x1="3" y1="6" x2="21" y2="6"></line>
412 <line x1="3" y1="12" x2="21" y2="12"></line>
413 <line x1="3" y1="18" x2="21" y2="18"></line>
414 </svg>
415 </button>
416
417 <Player />
418{/if}
419<Toast />
420<SearchModal />
421<LogoutModal />
422
423<style>
424 :global(*),
425 :global(*::before),
426 :global(*::after) {
427 box-sizing: border-box;
428 }
429
430 :global(:root) {
431 /* layout */
432 --queue-width: 0px;
433
434 /* accent colors - configurable */
435 --accent: #6a9fff;
436 --accent-hover: #8ab3ff;
437 --accent-muted: #4a7ddd;
438 --accent-rgb: 106, 159, 255;
439
440 /* backgrounds */
441 --bg-primary: #0a0a0a;
442 --bg-secondary: #141414;
443 --bg-tertiary: #1a1a1a;
444 --bg-hover: #1f1f1f;
445
446 /* borders */
447 --border-subtle: #282828;
448 --border-default: #333333;
449 --border-emphasis: #444444;
450
451 /* text */
452 --text-primary: #e8e8e8;
453 --text-secondary: #b0b0b0;
454 --text-tertiary: #808080;
455 --text-muted: #666666;
456
457 /* typography scale */
458 --text-xs: 0.75rem;
459 --text-sm: 0.85rem;
460 --text-base: 0.9rem;
461 --text-lg: 1rem;
462 --text-xl: 1.1rem;
463 --text-2xl: 1.25rem;
464 --text-3xl: 1.5rem;
465
466 /* semantic typography (aliases) */
467 --text-page-heading: var(--text-3xl);
468 --text-section-heading: 1.2rem;
469 --text-body: var(--text-lg);
470 --text-small: var(--text-base);
471
472 /* border radius scale */
473 --radius-sm: 4px;
474 --radius-base: 6px;
475 --radius-md: 8px;
476 --radius-lg: 12px;
477 --radius-xl: 16px;
478 --radius-2xl: 24px;
479 --radius-full: 9999px;
480
481 /* semantic */
482 --success: #4ade80;
483 --warning: #fbbf24;
484 --error: #ef4444;
485
486 /* glass effects (dark theme) */
487 --glass-bg: rgba(20, 20, 20, 0.75);
488 --glass-blur: blur(12px);
489 --glass-border: rgba(255, 255, 255, 0.06);
490
491 /* track item glass (no blur, just translucent) */
492 --track-bg: rgba(18, 18, 18, 0.88);
493 --track-bg-hover: rgba(24, 24, 24, 0.92);
494 --track-bg-playing: rgba(18, 18, 18, 0.88);
495 --track-border: rgba(255, 255, 255, 0.06);
496 --track-border-hover: rgba(255, 255, 255, 0.1);
497 }
498
499 /* light theme overrides */
500 :global(:root.theme-light) {
501 --bg-primary: #fafafa;
502 --bg-secondary: #ffffff;
503 --bg-tertiary: #f5f5f5;
504 --bg-hover: #ebebeb;
505
506 --border-subtle: #e5e5e5;
507 --border-default: #d4d4d4;
508 --border-emphasis: #a3a3a3;
509
510 --text-primary: #171717;
511 --text-secondary: #525252;
512 --text-tertiary: #737373;
513 --text-muted: #a3a3a3;
514
515 /* accent colors preserved from user preference */
516 /* accent-muted darkened for light bg readability */
517 --accent-muted: color-mix(in srgb, var(--accent) 70%, black);
518
519 /* semantic colors adjusted for light bg */
520 --success: #16a34a;
521 --warning: #d97706;
522 --error: #dc2626;
523
524 /* glass effects (light theme) */
525 --glass-bg: rgba(250, 250, 250, 0.75);
526 --glass-border: rgba(0, 0, 0, 0.06);
527
528 /* track item glass (light theme) */
529 --track-bg: rgba(255, 255, 255, 0.94);
530 --track-bg-hover: rgba(250, 250, 250, 0.96);
531 --track-bg-playing: rgba(255, 255, 255, 0.94);
532 --track-border: rgba(0, 0, 0, 0.08);
533 --track-border-hover: rgba(0, 0, 0, 0.12);
534 }
535
536 /* light theme specific overrides for components */
537 :global(:root.theme-light) :global(.tag-badge) {
538 background: color-mix(in srgb, var(--accent) 12%, white);
539 color: var(--accent-muted);
540 }
541
542 /* shared animation for active play buttons */
543 @keyframes -global-ethereal-glow {
544 0%, 100% {
545 box-shadow: 0 0 8px 1px color-mix(in srgb, var(--accent) 25%, transparent);
546 }
547 50% {
548 box-shadow: 0 0 14px 3px color-mix(in srgb, var(--accent) 45%, transparent);
549 }
550 }
551
552 :global(body) {
553 margin: 0;
554 padding: 0;
555 font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace;
556 background-color: var(--bg-primary);
557 color: var(--text-primary);
558 -webkit-font-smoothing: antialiased;
559 }
560
561 /* background image with blur effect */
562 :global(body::before) {
563 content: '';
564 position: fixed;
565 inset: 0;
566 background-image: var(--bg-image, none);
567 background-repeat: var(--bg-image-mode, no-repeat);
568 background-size: var(--bg-image-size, cover);
569 background-position: center;
570 filter: blur(var(--bg-blur, 0px));
571 transform: scale(1.1); /* prevent blur edge artifacts */
572 z-index: -1;
573 pointer-events: none;
574 }
575
576 .app-layout {
577 display: flex;
578 min-height: 100vh; /* fallback for browsers without dvh support */
579 width: 100%;
580 overflow-x: clip; /* clip instead of hidden to preserve position: sticky on descendants */
581 }
582
583 @supports (min-height: 100dvh) {
584 .app-layout {
585 min-height: 100dvh; /* dynamic viewport height (accounts for mobile browser UI) */
586 }
587 }
588
589 .main-content {
590 flex: 1;
591 min-width: 0;
592 width: 100%;
593 transition: margin-right 0.3s ease;
594 }
595
596 .main-content.with-queue {
597 margin-right: 360px;
598 }
599
600 .queue-sidebar {
601 position: fixed;
602 top: 0;
603 right: 0;
604 width: min(360px, 100%);
605 height: 100vh; /* fallback for browsers without dvh support */
606 background: var(--glass-bg, var(--bg-primary));
607 backdrop-filter: var(--glass-blur, none);
608 -webkit-backdrop-filter: var(--glass-blur, none);
609 border-left: 1px solid var(--glass-border, var(--border-subtle));
610 z-index: 50;
611 }
612
613 @supports (height: 100dvh) {
614 .queue-sidebar {
615 height: 100dvh; /* dynamic viewport height (accounts for mobile browser UI) */
616 }
617 }
618
619 .queue-toggle {
620 position: fixed;
621 bottom: calc(var(--player-height, 0px) + 20px + env(safe-area-inset-bottom, 0px));
622 right: 20px;
623 width: 48px;
624 height: 48px;
625 border-radius: var(--radius-full);
626 background: var(--bg-secondary);
627 border: 1px solid var(--border-default);
628 color: var(--text-secondary);
629 cursor: pointer;
630 display: flex;
631 align-items: center;
632 justify-content: center;
633 transition: all 0.2s;
634 z-index: 60;
635 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
636 transform: translate3d(0, var(--visual-viewport-offset, 0px), 0);
637 will-change: transform;
638 }
639
640 .queue-toggle:hover {
641 background: var(--bg-hover);
642 color: var(--accent);
643 border-color: var(--accent);
644 transform: translate3d(0, var(--visual-viewport-offset, 0px), 0) scale(1.05);
645 }
646
647 @media (max-width: 768px) {
648 .main-content.with-queue {
649 margin-right: 0;
650 }
651
652 .queue-sidebar {
653 width: 100%;
654 }
655
656 .queue-toggle {
657 bottom: calc(var(--player-height, 0px) + 20px + env(safe-area-inset-bottom, 0px));
658 }
659 }
660</style>