diff --git a/bun.lock b/bun.lock index 9db95dd..13aca0b 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@capacitor/core": "^8.2.0", "@capacitor/haptics": "^8.0.1", "@capacitor/ios": "^8.2.0", - "@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/ebd0e369b706c127a280d4ad631977f8d12ff88f.tar.gz", + "@dantheman827/taglib-ts": "^0.1.5", "@ffmpeg/core": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", @@ -69,7 +69,7 @@ "overrides": { "serialize-javascript": "^7.0.3", "source-map": "^0.7.4", - "sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14", + "sourcemap-codec": "^1.4.14", }, "packages": { "@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.7", "", { "dependencies": { "jsonpointer": "^5.0.1", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw=="], @@ -300,7 +300,7 @@ "@csstools/selector-specificity": ["@csstools/selector-specificity@6.0.0", "", { "peerDependencies": { "postcss-selector-parser": "^7.1.1" } }, "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA=="], - "@dantheman827/taglib-ts": ["@dantheman827/taglib-ts@https://github.com/DanTheMan827/taglib-ts/archive/ebd0e369b706c127a280d4ad631977f8d12ff88f.tar.gz", {}, "sha512-QPl5eWhFqP716VKIX5t7x39eRswRHG+5RpQ9wW6cNbDh1QSa/gWpPyvwP55O+/qCV7VjLepZI1/XFoxMO8jK7w=="], + "@dantheman827/taglib-ts": ["@dantheman827/taglib-ts@0.1.5", "", {}, "sha512-uOMzKccEE0AUsMZ6rTPSUB0ZqAvuRxih9ECn8N62lMNi0UsXQumuKN86F3CxNaF4y3LOkvtp45vplTjwEEUOlQ=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], diff --git a/js/player.js b/js/player.js index be3a0a6..44c43b6 100644 --- a/js/player.js +++ b/js/player.js @@ -47,6 +47,8 @@ export class Player { this.shuffleActive = false; this.repeatMode = REPEAT_MODE.OFF; this.preloadCache = new Map(); + this._pendingPreload = false; + setInterval(this.checkPreloadConditions.bind(this), 2000); this.preloadAbortController = null; this.currentTrack = null; this.currentRgValues = null; @@ -473,7 +475,27 @@ export class Player { this.quality = quality; } - async preloadNextTracks() { + preloadNextTracks() { + this._pendingPreload = true; + } + + async checkPreloadConditions() { + if (!this._pendingPreload || !this.activeElement || this.activeElement.paused) return; + + const currentTime = this.activeElement.currentTime || 0; + const duration = this.activeElement.duration || 0; + const timeRemaining = duration - currentTime; + + // Preload if we are in last 30 seconds of song + const shouldPreload = (duration > 0 && timeRemaining <= 30); + + if (shouldPreload) { + this._pendingPreload = false; + void this._executePreloadNextTracks().catch(console.error); + } + } + + async _executePreloadNextTracks() { if (this.preloadAbortController) { this.preloadAbortController.abort(); } @@ -482,7 +504,8 @@ export class Player { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const tracksToPreload = []; - for (let i = 1; i <= 2; i++) { + // Only preload the next 1 song to prevent data waste + for (let i = 1; i <= 1; i++) { const nextIndex = this.currentQueueIndex + i; if (nextIndex < currentQueue.length) { tracksToPreload.push({ track: currentQueue[nextIndex], index: nextIndex }); @@ -502,13 +525,52 @@ export class Player { if (this.preloadAbortController.signal.aborted) break; + // Also preload ReplayGain legacy metadata if the fast manifest endpoint failed to provide it + if (track.type !== 'video' && !streamInfo.rgInfo) { + try { + const trackData = await this.api.getTrack(track.id, this.quality); + if (trackData && trackData.info) { + streamInfo.rgInfoFallback = { + trackReplayGain: trackData.info.trackReplayGain, + trackPeakAmplitude: trackData.info.trackPeakAmplitude, + albumReplayGain: trackData.info.albumReplayGain, + albumPeakAmplitude: trackData.info.albumPeakAmplitude, + }; + } + } catch(e) {} // Fail silently + } + this.preloadCache.set(track.id, streamInfo); - // Warm connection/cache - // For Blob URLs (DASH), this head request is not needed and can cause errors. - if (!streamInfo.url.startsWith('blob:')) { - fetch(streamInfo.url, { method: 'HEAD', signal: this.preloadAbortController.signal }).catch( - () => {} - ); + const streamUrl = streamInfo.url; + + // Warm connection and pre-fetch + if (!streamUrl.startsWith('blob:')) { + if (streamUrl.includes('.mpd') || streamUrl.includes('.m3u8')) { + if (this.shakaInitialized && this.shakaPlayer && typeof this.shakaPlayer.preload === 'function') { + try { + const preloadManager = await this.shakaPlayer.preload(streamUrl); + streamInfo.preloadManager = preloadManager; + } catch (e) { + // Ignore preload errors, will just load fresh + } + } else { + fetch(streamUrl, { method: 'GET', signal: this.preloadAbortController.signal }).catch( + () => {} + ); + } + } else { + // For static files (FLAC, MP3), standard fetch of the first ~5MB completely primes the cache. + const preloader = new Audio(); + preloader.preload = 'auto'; + preloader.muted = true; + preloader.src = streamUrl; + streamInfo.preloader = preloader; // Hold reference + + fetch(streamUrl, { + headers: { Range: 'bytes=0-5242880' }, + signal: this.preloadAbortController.signal, + }).catch(() => {}); + } } } catch (error) { if (error.name !== 'AbortError') { @@ -720,9 +782,13 @@ export class Player { this.hls.destroy(); this.hls = null; } + + // Retain the initialized Shaka player if we are remaining on the same HTMLMediaElement if (this.shakaInitialized && this.shakaPlayer) { - this.shakaPlayer.unload(); - this.shakaInitialized = false; + if (this.shakaPlayer.getMediaElement() !== activeElement) { + this.shakaPlayer.unload(); + this.shakaInitialized = false; + } } if (inactiveElement) { @@ -736,9 +802,13 @@ export class Player { } if (activeElement) { - activeElement.pause(); - activeElement.src = ''; - activeElement.removeAttribute('src'); + // Let Shaka overwrite the activeElement's decoder pipeline gracefully if we're carrying it over. + // It manages its own buffering teardown implicitly when `load()` is executed. + if (!this.shakaInitialized) { + activeElement.pause(); + activeElement.src = ''; + activeElement.removeAttribute('src'); + } } audioContextManager.changeSource(activeElement); @@ -925,7 +995,17 @@ export class Player { await this.setupHlsVideo(activeElement, streamUrl, null); } else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) { await this.shakaPlayer.attach(activeElement); - await this.shakaPlayer.load(streamUrl); + + const loadTarget = track.type == 'video' && this.preloadCache.has(track.id) ? + (this.preloadCache.get(track.id).preloadManager || streamUrl) : streamUrl; + + try { + await this.shakaPlayer.load(loadTarget); + } catch (e) { + console.error("PreloadManager load Error:", e); if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl); + else throw e; + } + this.shakaInitialized = true; const savedAdaptiveQuality = localStorage.getItem('adaptive-playback-quality') || 'auto'; @@ -938,9 +1018,6 @@ export class Player { this.applyAudioEffects(); - const canPlay = await this.waitForCanPlayOrTimeout(activeElement); - if (!canPlay || this.playbackSequence !== currentSequence) return; - if (startTime > 0) { activeElement.currentTime = startTime; } @@ -961,6 +1038,9 @@ export class Player { if (resolvedStreamInfo.rgInfo) { this.currentRgValues = resolvedStreamInfo.rgInfo; this.applyReplayGain(); + } else if (resolvedStreamInfo.rgInfoFallback) { + this.currentRgValues = resolvedStreamInfo.rgInfoFallback; + this.applyReplayGain(); } else { // Fallback to legacy metadata if manifest lacked normalization data const trackData = await this.api.getTrack(track.id, this.quality).catch(() => null); @@ -984,12 +1064,24 @@ export class Player { // Handle playback if (streamUrl && (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) && !track.isLocal) { // It's likely a DASH manifest URL - await this.shakaPlayer.attach(activeElement); - if (startTime > 0) { - await this.shakaPlayer.load(streamUrl, startTime); - } else { - await this.shakaPlayer.load(streamUrl); + if (this.shakaPlayer.getMediaElement() !== activeElement) { + await this.shakaPlayer.attach(activeElement); + this.shakaInitialized = true; } + + const loadTarget = resolvedStreamInfo.preloadManager || streamUrl; + + try { + if (startTime > 0) { + await this.shakaPlayer.load(loadTarget, startTime); + } else { + await this.shakaPlayer.load(loadTarget); + } + } catch (e) { + console.error("PreloadManager load Error:", e); if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl); + else throw e; + } + this.shakaInitialized = true; this.applyAudioEffects(); @@ -998,18 +1090,14 @@ export class Player { this.updateAdaptiveQualityBadge(); - const canPlay = await this.waitForCanPlayOrTimeout(activeElement); - if (!canPlay || this.playbackSequence !== currentSequence) return; + // Instantly trigger playback rather than explicitly waiting for 'canplay' + // which delays the event loop and natively adds gap/latency await this.safePlay(activeElement); } else { activeElement.src = streamUrl; this.applyAudioEffects(); this.updateAdaptiveQualityBadge(); - // Wait for audio to be ready before playing - const canPlay = await this.waitForCanPlayOrTimeout(activeElement); - if (!canPlay || this.playbackSequence !== currentSequence) return; - if (startTime > 0) { activeElement.currentTime = startTime; } @@ -1046,10 +1134,6 @@ export class Player { } console.error(`Could not play track: ${trackTitle}`, error); - // Skip to next track on unexpected error - if (recursiveCount < currentQueue.length) { - setTimeout(() => this.playNext(recursiveCount + 1), 1000); - } } } diff --git a/package.json b/package.json index c0e6b0c..b86a30b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "vite-plugin-pwa": "^1.2.0" }, "overrides": { - "sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14", + "sourcemap-codec": "^1.4.14", "source-map": "^0.7.4", "serialize-javascript": "^7.0.3" }, @@ -63,7 +63,7 @@ "@capacitor/core": "^8.2.0", "@capacitor/haptics": "^8.0.1", "@capacitor/ios": "^8.2.0", - "@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/ebd0e369b706c127a280d4ad631977f8d12ff88f.tar.gz", + "@dantheman827/taglib-ts": "^0.1.5", "@ffmpeg/core": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2",