Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 30 additions & 58 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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 */
Expand Down Expand Up @@ -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 */
Expand Down
6 changes: 4 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand Down
143 changes: 134 additions & 9 deletions src/lib/components/AudioPlayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement | null>(null);
let resizeObserver = $state<ResizeObserver | null>(null);
let showQueuePanel = $state(false);
const streamCache = new Map<
string,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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<void>(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();
}
}

Expand All @@ -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'
Expand All @@ -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<void>(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();
Expand Down
Loading