at main 19 kB view raw
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>