diff --git a/src/app.css b/src/app.css index 3b44819b..18b45db9 100644 --- a/src/app.css +++ b/src/app.css @@ -2,6 +2,34 @@ @plugin '@tailwindcss/forms'; @plugin '@tailwindcss/typography'; +/* Custom scrollbar styling */ +::-webkit-scrollbar { + width: auto; + height: auto; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.5); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(148, 163, 184, 0.7); +} + +html { + scrollbar-width: auto; + scrollbar-color: rgba(148, 163, 184, 0.5) transparent; +} + +html:hover { + scrollbar-color: rgba(148, 163, 184, 0.7) transparent; +} + button, [type='button'], [type='submit'], @@ -26,53 +54,14 @@ a[aria-disabled='true'] { ============================================ */ /* Performance-optimized blur and effects */ -:root[data-performance='medium'] { - --perf-blur-high: 22px; - --perf-blur-medium: 18px; - --perf-blur-low: 14px; - --perf-saturate: 125%; -} - -:root[data-performance='low'] { +/* Performance mode is applied globally by default */ +:root { --perf-blur-high: 0px; --perf-blur-medium: 0px; --perf-blur-low: 0px; --perf-saturate: 100%; } -:root[data-performance='low'] * { - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; -} - -:root[data-performance='low'] .glass-panel, -:root[data-performance='low'] .glass-toolbar, -:root[data-performance='low'] .settings-menu, -:root[data-performance='low'] .toolbar-button, -:root[data-performance='low'] .glass-option, -:root[data-performance='low'] .glass-action, -:root[data-performance='low'] .glass-option__chip, -:root[data-performance='low'] .glass-panel__footer, -:root[data-performance='low'] .navigation-overlay, -:root[data-performance='low'] .navigation-overlay__progress { - background-color: rgba(15, 23, 42, 0.92) !important; - border-color: rgba(148, 163, 184, 0.2) !important; - box-shadow: none !important; -} - -:root[data-performance='low'] .toolbar-button, -:root[data-performance='low'] .glass-option, -:root[data-performance='low'] .glass-action { - transition: none; -} - -:root:not([data-performance='medium']):not([data-performance='low']) { - --perf-blur-high: 32px; - --perf-blur-medium: 28px; - --perf-blur-low: 24px; - --perf-saturate: 160%; -} - /* Enhanced Color Palette */ :root { /* Primary Blues */ @@ -229,23 +218,6 @@ a[aria-disabled='true'] { MOBILE PERFORMANCE OPTIMIZATIONS ============================================ */ -/* Mobile-specific blur reduction (major GPU savings) */ -@media (max-width: 768px) { - :root:not([data-performance='low']) { - --perf-blur-high: 16px; - --perf-blur-medium: 12px; - --perf-blur-low: 8px; - --perf-saturate: 130%; - } - - :root[data-performance='medium'] { - --perf-blur-high: 12px; - --perf-blur-medium: 8px; - --perf-blur-low: 6px; - --perf-saturate: 120%; - } -} - /* Touch device optimizations */ @media (pointer: coarse) { /* Disable hover effects that cause sticky states on touch */ diff --git a/src/lib/api.ts b/src/lib/api.ts index 82500fd6..65857ccd 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1497,7 +1497,8 @@ class LosslessAPI { // Try to fetch metadata for replay gain, but don't fail if it fails try { const lookup = await this.getTrack(trackId, quality); - replayGain = lookup.info.trackReplayGain ?? null; + // Use track replay gain, fall back to album replay gain if not available + replayGain = lookup.info.trackReplayGain ?? lookup.info.albumReplayGain ?? null; sampleRate = lookup.info.sampleRate ?? null; bitDepth = lookup.info.bitDepth ?? null; } catch { @@ -1517,7 +1518,8 @@ class LosslessAPI { for (let attempt = 1; attempt <= 3; attempt += 1) { try { const lookup = await this.getTrack(trackId, quality); - replayGain = lookup.info.trackReplayGain ?? null; + // Use track replay gain, fall back to album replay gain if not available + replayGain = lookup.info.trackReplayGain ?? lookup.info.albumReplayGain ?? null; sampleRate = lookup.info.sampleRate ?? null; bitDepth = lookup.info.bitDepth ?? null; diff --git a/src/lib/components/AudioPlayer.svelte b/src/lib/components/AudioPlayer.svelte index 6a6cda36..d258acd7 100644 --- a/src/lib/components/AudioPlayer.svelte +++ b/src/lib/components/AudioPlayer.svelte @@ -66,8 +66,8 @@ let downloadTaskIdForCurrentTrack: string | null = null; const { onHeightChange = () => {}, headless = false } = $props<{ onHeightChange?: (height: number) => void, headless?: boolean }>(); - let containerElement: HTMLDivElement | null = null; - let resizeObserver: ResizeObserver | null = null; + let containerElement = $state(null); + let resizeObserver = $state(null); let showQueuePanel = $state(false); const streamCache = new Map< string, @@ -380,6 +380,20 @@ return trackLookup.track; } + function applyVolumeToAudioElement(replayGain: number | null): void { + if (!audioElement) return; + + const baseVolume = $playerStore.volume; + if (replayGain !== null && typeof replayGain === 'number') { + // Apply replay gain directly: volume * 10^(gain / 20) + const gainFactor = Math.pow(10, replayGain / 20); + const adjusted = baseVolume * gainFactor; + audioElement.volume = Math.min(1, Math.max(0, adjusted)); + } else { + audioElement.volume = baseVolume; + } + } + function maybePreloadNextTrack(remainingSeconds: number) { if (remainingSeconds > PRELOAD_THRESHOLD_SECONDS) { return; @@ -553,6 +567,8 @@ $effect(() => { if ($playerStore.isPlaying && audioElement) { + // Ensure volume is correct before playing to prevent audio spike + applyVolumeToAudioElement($playerStore.replayGain); audioElement.play().catch(console.error); } else if (!$playerStore.isPlaying && audioElement) { audioElement.pause(); @@ -562,19 +578,62 @@ async function loadStandardTrack(track: Track, quality: AudioQuality, sequence: number) { await destroyShakaPlayer(); dashPlaybackActive = false; + + // Stop all playback first and mute immediately to prevent audio bleeding + playerStore.pause(); + if (audioElement) { + audioElement.pause(); + audioElement.currentTime = 0; + audioElement.volume = 0; + } + const { url, replayGain, sampleRate, bitDepth } = await resolveStream(track, quality); if (sequence !== loadSequence) { return; } + + // Load new audio source with fresh position + if (audioElement) { + audioElement.src = url; + audioElement.crossOrigin = 'anonymous'; + audioElement.currentTime = 0; + audioElement.load(); + } + + // Store metadata for later application streamUrl = url; currentPlaybackQuality = quality; - playerStore.setReplayGain(replayGain); playerStore.setSampleRate(sampleRate); playerStore.setBitDepth(bitDepth); pruneStreamCache(); - if (audioElement) { - audioElement.crossOrigin = 'anonymous'; - audioElement.load(); + + // Wait for audio to be fully loaded and ready to play + await new Promise(resolve => { + const onCanPlay = () => { + audioElement?.removeEventListener('canplay', onCanPlay); + resolve(); + }; + if (audioElement) { + audioElement.addEventListener('canplay', onCanPlay, { once: true }); + // Timeout after 5 seconds + setTimeout(() => { + audioElement?.removeEventListener('canplay', onCanPlay); + resolve(); + }, 5000); + } else { + resolve(); + } + }); + + // Audio is now fully ready - apply replay gain directly and play + if (sequence === loadSequence) { + // Apply volume synchronously before playing - don't update store yet + applyVolumeToAudioElement(replayGain); + // NOW update store so future volume changes work correctly + // This won't trigger volume effect since audio hasn't started yet + playerStore.setReplayGain(replayGain); + // Play immediately - volume is already correct + playerStore.play(); } } @@ -595,6 +654,15 @@ dashPlaybackActive = false; return cached; } + + // Stop playback first and mute immediately to prevent audio bleeding + playerStore.pause(); + if (audioElement) { + audioElement.pause(); + audioElement.currentTime = 0; + audioElement.volume = 0; + } + revokeHiResObjectUrl(); const blob = new Blob([manifestResult.manifest], { type: manifestResult.contentType ?? 'application/dash+xml' @@ -607,21 +675,78 @@ if (audioElement) { audioElement.pause(); audioElement.removeAttribute('src'); + audioElement.currentTime = 0; audioElement.load(); } await player.unload(); await player.load(hiResObjectUrl); + dashPlaybackActive = true; streamUrl = ''; currentPlaybackQuality = 'HI_RES_LOSSLESS'; - // Apply metadata directly from the API response - no second API call needed + // Apply metadata if (sequence === loadSequence && currentTrackId === track.id) { playerStore.setSampleRate(trackInfo.sampleRate); playerStore.setBitDepth(trackInfo.bitDepth); - if (trackInfo.replayGain !== null) { - playerStore.setReplayGain(trackInfo.replayGain); + } + + // Wait for media to be ready before seeking + // Use both timeout and event-based waiting to be safe + let ready = false; + const onCanPlay = () => { + ready = true; + audioElement?.removeEventListener('canplay', onCanPlay); + }; + const onLoadedMetadata = () => { + ready = true; + audioElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + }; + + if (audioElement) { + audioElement.addEventListener('canplay', onCanPlay); + audioElement.addEventListener('loadedmetadata', onLoadedMetadata); + } + + // Wait up to 2 seconds for media ready event + const maxWait = 2000; + const checkInterval = 50; + let elapsed = 0; + while (!ready && elapsed < maxWait) { + await new Promise(resolve => setTimeout(resolve, checkInterval)); + elapsed += checkInterval; + } + + // Clean up listeners + if (audioElement) { + audioElement.removeEventListener('canplay', onCanPlay); + audioElement.removeEventListener('loadedmetadata', onLoadedMetadata); + } + + // Now seek to 0 after media is ready + try { + if (sequence === loadSequence) { + player.seek(0); + if (audioElement) { + audioElement.currentTime = 0; + } } + } catch (e) { + console.debug('Failed to seek Shaka player to 0:', e); + if (audioElement) { + audioElement.currentTime = 0; + } + } + + // Audio is ready - apply replay gain directly and play + if (sequence === loadSequence && currentTrackId === track.id) { + // Apply volume synchronously before playing - don't update store yet + applyVolumeToAudioElement(trackInfo.replayGain); + // NOW update store so future volume changes work correctly + // This won't trigger volume effect since audio hasn't started yet + playerStore.setReplayGain(trackInfo.replayGain); + // Play immediately - volume is already correct + playerStore.play(); } pruneDashManifestCache(); diff --git a/src/lib/components/DynamicBackground.svelte b/src/lib/components/DynamicBackground.svelte deleted file mode 100644 index 4e490d6f..00000000 --- a/src/lib/components/DynamicBackground.svelte +++ /dev/null @@ -1,904 +0,0 @@ - - - - - diff --git a/src/lib/components/DynamicBackgroundWebGL.svelte b/src/lib/components/DynamicBackgroundWebGL.svelte deleted file mode 100644 index 2a85f8f3..00000000 --- a/src/lib/components/DynamicBackgroundWebGL.svelte +++ /dev/null @@ -1,573 +0,0 @@ - - -
- -
- - diff --git a/src/lib/components/SearchInterface.svelte b/src/lib/components/SearchInterface.svelte index 062f5efb..db99551c 100644 --- a/src/lib/components/SearchInterface.svelte +++ b/src/lib/components/SearchInterface.svelte @@ -157,6 +157,11 @@ let albumDownloadStates = $state>({}); const newsItems = [ + { + title: 'v3.5 - UI Polish & Performance Work', + description: + 'Quality of life improvements across the board: refined settings menu, better scrollbar styling, improved button layouts, and audio playback fixes. Note: We are actively working on fixing performance issues on Chrome-based browsers. Please report any slowdowns!' + }, { title: 'Hi-Res downloading!!!', description: diff --git a/src/lib/components/ShareButton.svelte b/src/lib/components/ShareButton.svelte index 75655a62..e6ab7728 100644 --- a/src/lib/components/ShareButton.svelte +++ b/src/lib/components/ShareButton.svelte @@ -10,7 +10,9 @@ title?: string; size?: number; iconOnly?: boolean; - variant?: 'ghost' | 'primary' | 'secondary'; + variant?: 'ghost' | 'primary' | 'secondary' | 'minimal'; + dropdownPosition?: 'above' | 'below'; + noRound?: boolean; } let { @@ -19,13 +21,15 @@ title = 'Share', size = 20, iconOnly = false, - variant = 'ghost' + variant = 'ghost', + dropdownPosition = 'below', + noRound = false }: Props = $props(); let showMenu = $state(false); let copied = $state(false); - let menuRef: HTMLDivElement | null = null; - let buttonRef: HTMLButtonElement | null = null; + let menuRef = $state(null); + let buttonRef = $state(null); function getLongLink() { return `${$page.url.protocol}://${$page.url.host}/${type}/${id}`; @@ -102,14 +106,15 @@ const variantClasses = { ghost: 'text-gray-400 hover:text-white hover:bg-white/10', primary: 'bg-blue-600 text-white hover:bg-blue-700', - secondary: 'bg-gray-800 text-white hover:bg-gray-700' + secondary: 'border border-white/30 text-white hover:bg-white/10 hover:border-white/50 bg-white/5 backdrop-blur-sm', + minimal: 'text-gray-400' };
- -
- -
- -
-
-

Performance Mode

-
- {#each PERFORMANCE_OPTIONS as option} - - {/each} -
-
-
+

Queue actions