//player.js import { REPEAT_MODE, formatTime } from './utils.js'; export class Player { constructor(audioElement, api, quality = 'LOSSLESS') { this.audio = audioElement; this.api = api; this.quality = quality; this.queue = []; this.shuffledQueue = []; this.originalQueueBeforeShuffle = []; this.currentQueueIndex = -1; this.shuffleActive = false; this.repeatMode = REPEAT_MODE.OFF; this.preloadCache = new Map(); this.preloadAbortController = null; this.currentTrack = null; this.crossfadeEnabled = false; this.crossfadeDuration = 5; this.nextAudioElement = null; this.isCrossfading = false; this.setupMediaSession(); this.setupCrossfade(); } setupCrossfade() { this.nextAudioElement = document.createElement('audio'); this.nextAudioElement.preload = 'auto'; } setCrossfade(enabled, duration = 5) { this.crossfadeEnabled = enabled; this.crossfadeDuration = Math.max(1, Math.min(12, duration)); } setupMediaSession() { if (!('mediaSession' in navigator)) return; navigator.mediaSession.setActionHandler('play', () => { this.audio.play().catch(console.error); }); navigator.mediaSession.setActionHandler('pause', () => { this.audio.pause(); }); navigator.mediaSession.setActionHandler('previoustrack', () => { this.playPrev(); }); navigator.mediaSession.setActionHandler('nexttrack', () => { this.playNext(); }); navigator.mediaSession.setActionHandler('seekbackward', (details) => { const skipTime = details.seekOffset || 10; this.seekBackward(skipTime); }); navigator.mediaSession.setActionHandler('seekforward', (details) => { const skipTime = details.seekOffset || 10; this.seekForward(skipTime); }); navigator.mediaSession.setActionHandler('seekto', (details) => { if (details.seekTime !== undefined) { this.audio.currentTime = Math.max(0, details.seekTime); this.updateMediaSessionPositionState(); } }); navigator.mediaSession.setActionHandler('stop', () => { this.audio.pause(); this.audio.currentTime = 0; this.updateMediaSessionPlaybackState(); }); } setQuality(quality) { this.quality = quality; } async preloadNextTracks() { if (this.preloadAbortController) { this.preloadAbortController.abort(); } this.preloadAbortController = new AbortController(); const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const tracksToPreload = []; for (let i = 1; i <= 2; i++) { const nextIndex = this.currentQueueIndex + i; if (nextIndex < currentQueue.length) { tracksToPreload.push({ track: currentQueue[nextIndex], index: nextIndex }); } } for (const { track, index } of tracksToPreload) { if (this.preloadCache.has(track.id)) continue; try { const streamUrl = await this.api.getStreamUrl(track.id, this.quality); if (this.preloadAbortController.signal.aborted) break; this.preloadCache.set(track.id, streamUrl); if (index === this.currentQueueIndex + 1 && this.crossfadeEnabled) { this.nextAudioElement.src = streamUrl; } } catch (error) { if (error.name !== 'AbortError') { console.debug('Failed to get stream URL for preload:', track.title); } } } } async playTrackFromQueue() { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) { return; } const track = currentQueue[this.currentQueueIndex]; this.currentTrack = track; document.querySelector('.now-playing-bar .cover').src = this.api.getCoverUrl(track.album?.cover, '1280'); document.querySelector('.now-playing-bar .title').textContent = track.title; document.querySelector('.now-playing-bar .artist').textContent = track.artist?.name || 'Unknown Artist'; document.title = `${track.title} • ${track.artist?.name || 'Unknown'}`; this.updatePlayingTrackIndicator(); this.updateMediaSession(track); try { let streamUrl; if (this.preloadCache.has(track.id)) { streamUrl = this.preloadCache.get(track.id); } else { const trackData = await this.api.getTrack(track.id, this.quality); // Store replayGain for normalization if (trackData.track?.replayGain !== undefined) { window.currentGain = trackData.track.replayGain; } else { window.currentGain = track.replayGain || null; } if (trackData.originalTrackUrl) { streamUrl = trackData.originalTrackUrl; } else { streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest); } } if (this.isCrossfading && this.nextAudioElement.src === streamUrl) { const temp = this.audio; this.audio = this.nextAudioElement; this.nextAudioElement = temp; this.nextAudioElement.pause(); this.nextAudioElement.currentTime = 0; } else { this.audio.src = streamUrl; } // Apply normalization if enabled this.applyNormalization(); await this.audio.play(); this.isCrossfading = false; this.updateMediaSessionPlaybackState(); this.preloadNextTracks(); this.setupCrossfadeListener(); } catch (error) { console.error(`Could not play track: ${track.title}`, error); document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`; document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track'; } } setupCrossfadeListener() { if (!this.crossfadeEnabled) return; const checkCrossfade = () => { const timeRemaining = this.audio.duration - this.audio.currentTime; if (timeRemaining <= this.crossfadeDuration && timeRemaining > 0 && !this.isCrossfading) { this.startCrossfade(); } }; this.audio.removeEventListener('timeupdate', this.crossfadeCheck); this.crossfadeCheck = checkCrossfade; this.audio.addEventListener('timeupdate', this.crossfadeCheck); } async startCrossfade() { if (this.repeatMode === REPEAT_MODE.ONE) return; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const nextIndex = this.currentQueueIndex + 1; if (nextIndex >= currentQueue.length && this.repeatMode !== REPEAT_MODE.ALL) return; this.isCrossfading = true; const targetIndex = nextIndex >= currentQueue.length ? 0 : nextIndex; const nextTrack = currentQueue[targetIndex]; if (this.nextAudioElement.src && this.preloadCache.has(nextTrack.id)) { try { await this.nextAudioElement.play(); this.nextAudioElement.volume = 0; const fadeSteps = 20; const fadeInterval = (this.crossfadeDuration * 1000) / fadeSteps; let step = 0; const fadeTimer = setInterval(() => { step++; const progress = step / fadeSteps; this.audio.volume = Math.max(0, 1 - progress); this.nextAudioElement.volume = Math.min(1, progress); if (step >= fadeSteps) { clearInterval(fadeTimer); this.audio.pause(); this.audio.volume = 1; this.currentQueueIndex = targetIndex; this.playTrackFromQueue(); } }, fadeInterval); } catch (error) { console.error('Crossfade failed:', error); this.isCrossfading = false; } } } playAtIndex(index) { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (index >= 0 && index < currentQueue.length) { this.currentQueueIndex = index; this.playTrackFromQueue(); } } playNext() { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1; if (this.repeatMode === REPEAT_MODE.ONE) { this.audio.currentTime = 0; this.audio.play(); return; } if (!isLastTrack) { this.currentQueueIndex++; } else if (this.repeatMode === REPEAT_MODE.ALL) { this.currentQueueIndex = 0; } else { return; } this.playTrackFromQueue(); } playPrev() { if (this.audio.currentTime > 3) { this.audio.currentTime = 0; this.updateMediaSessionPositionState(); } else if (this.currentQueueIndex > 0) { this.currentQueueIndex--; this.playTrackFromQueue(); } } handlePlayPause() { if (!this.audio.src) return; if (this.audio.paused) { this.audio.play().catch(console.error); } else { this.audio.pause(); } } seekBackward(seconds = 10) { const newTime = Math.max(0, this.audio.currentTime - seconds); this.audio.currentTime = newTime; this.updateMediaSessionPositionState(); } seekForward(seconds = 10) { const duration = this.audio.duration || 0; const newTime = Math.min(duration, this.audio.currentTime + seconds); this.audio.currentTime = newTime; this.updateMediaSessionPositionState(); } toggleShuffle() { this.shuffleActive = !this.shuffleActive; if (this.shuffleActive) { this.originalQueueBeforeShuffle = [...this.queue]; const currentTrack = this.queue[this.currentQueueIndex]; this.shuffledQueue = [...this.queue].sort(() => Math.random() - 0.5); this.currentQueueIndex = this.shuffledQueue.findIndex(t => t.id === currentTrack?.id); if (this.currentQueueIndex === -1 && currentTrack) { this.shuffledQueue.unshift(currentTrack); this.currentQueueIndex = 0; } } else { const currentTrack = this.shuffledQueue[this.currentQueueIndex]; this.queue = [...this.originalQueueBeforeShuffle]; this.currentQueueIndex = this.queue.findIndex(t => t.id === currentTrack?.id); } this.preloadCache.clear(); this.preloadNextTracks(); } toggleRepeat() { this.repeatMode = (this.repeatMode + 1) % 3; return this.repeatMode; } setQueue(tracks, startIndex = 0) { this.queue = tracks; this.currentQueueIndex = startIndex; this.shuffleActive = false; this.preloadCache.clear(); } addToQueue(track) { this.queue.push(track); if (!this.currentTrack || this.currentQueueIndex === -1) { this.currentQueueIndex = this.queue.length - 1; this.playTrackFromQueue(); } } removeFromQueue(index) { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (index < 0 || index >= currentQueue.length) return; if (this.shuffleActive) { this.shuffledQueue.splice(index, 1); } else { this.queue.splice(index, 1); } if (index < this.currentQueueIndex) { this.currentQueueIndex--; } else if (index === this.currentQueueIndex) { if (currentQueue.length > 0) { this.playTrackFromQueue(); } } } moveInQueue(fromIndex, toIndex) { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (fromIndex < 0 || fromIndex >= currentQueue.length) return; if (toIndex < 0 || toIndex >= currentQueue.length) return; const [track] = currentQueue.splice(fromIndex, 1); currentQueue.splice(toIndex, 0, track); if (this.currentQueueIndex === fromIndex) { this.currentQueueIndex = toIndex; } else if (fromIndex < this.currentQueueIndex && toIndex >= this.currentQueueIndex) { this.currentQueueIndex--; } else if (fromIndex > this.currentQueueIndex && toIndex <= this.currentQueueIndex) { this.currentQueueIndex++; } } getCurrentQueue() { return this.shuffleActive ? this.shuffledQueue : this.queue; } updatePlayingTrackIndicator() { const currentTrack = this.getCurrentQueue()[this.currentQueueIndex]; document.querySelectorAll('.track-item').forEach(item => { item.classList.toggle('playing', currentTrack && item.dataset.trackId == currentTrack.id ); }); } updateMediaSession(track) { if (!('mediaSession' in navigator)) return; const artwork = []; const sizes = ['1280']; const coverId = track.album?.cover; if (coverId) { sizes.forEach(size => { artwork.push({ src: this.api.getCoverUrl(coverId, size), sizes: `${size}x${size}`, type: 'image/jpeg' }); }); } navigator.mediaSession.metadata = new MediaMetadata({ title: track.title || 'Unknown Title', artist: track.artist?.name || 'Unknown Artist', album: track.album?.title || 'Unknown Album', artwork: artwork.length > 0 ? artwork : undefined }); this.updateMediaSessionPlaybackState(); this.updateMediaSessionPositionState(); } applyNormalization() { const normalizeEnabled = localStorage.getItem('normalize-volume') === 'true'; if (normalizeEnabled && window.currentGain !== null && window.currentGain !== undefined) { const baseVolume = parseFloat(localStorage.getItem('base-volume') || '0.7'); const replayGain = parseFloat(window.currentGain); const adjustment = Math.pow(10, replayGain / 20); this.audio.volume = Math.min(1, Math.max(0, baseVolume * adjustment)); } } updateMediaSessionPlaybackState() { if (!('mediaSession' in navigator)) return; navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing'; } updateMediaSessionPositionState() { if (!('mediaSession' in navigator)) return; if (!('setPositionState' in navigator.mediaSession)) return; const duration = this.audio.duration; if (!duration || isNaN(duration) || !isFinite(duration)) { return; } try { navigator.mediaSession.setPositionState({ duration: duration, playbackRate: this.audio.playbackRate || 1, position: Math.min(this.audio.currentTime, duration) }); } catch (error) { console.debug('Failed to update Media Session position:', error); } } }