import { REPEAT_MODE, formatTime, getTrackArtists, getTrackTitle, getTrackArtistsHTML, getTrackYearDisplay, createQualityBadgeHTML, escapeHtml, deriveTrackQuality, } from './utils.js'; import { queueManager, replayGainSettings, trackDateSettings, exponentialVolumeSettings, audioEffectsSettings, radioSettings, } from './storage.js'; import { audioContextManager } from './audio-context.js'; import { isIos, isSafari } from './platform-detection.js'; import { db } from './db.js'; import { SVG_CLOCK, SVG_ATMOS } from './icons.js'; import { UIRenderer } from './ui.js'; export class Player { static #instance = null; static get instance() { if (!Player.#instance) { throw new Error('Player is not initialized. Call Player.initialize(audioElement, api) first.'); } return Player.#instance; } /** @private */ constructor(audioElement, api, quality = 'HI_RES_LOSSLESS') { this.audio = audioElement; this.video = document.getElementById('video-player'); 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.currentRgValues = null; this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7'); this.isFallbackRetry = false; this.isFallbackInProgress = false; this.autoplayBlocked = false; this.isIOS = isIos; this.isPwa = typeof window !== 'undefined' && (window.matchMedia?.('(display-mode: standalone)')?.matches || window.navigator?.standalone === true); this.hls = null; // Sleep timer properties this.sleepTimer = null; this.sleepTimerEndTime = null; this.sleepTimerInterval = null; // Artist popular tracks state this.artistPopularTracksState = { artistId: null, offset: 0, initialTracks: [], isFetching: false, hasMore: true, }; } static async initialize(audioElement, api, quality) { if (Player.#instance) { throw new Error('Player is already initialized'); } const player = new Player(audioElement, api, quality); await player.init(); Player.#instance = player; return player; } async init() { // Apply audio effects when track is ready this.audio.addEventListener('canplay', () => { this.applyAudioEffects(); }); if (this.video) { this.video.addEventListener('canplay', () => { this.applyAudioEffects(); }); } // Initialize Shaka player const shaka = await import('shaka-player'); shaka.polyfill.installAll(); if (shaka.Player.isBrowserSupported()) { this.shakaPlayer = new shaka.Player(); this.shakaPlayer.configure({ streaming: { bufferingGoal: 30, rebufferingGoal: 2, bufferBehind: 30, jumpLargeGaps: true, }, abr: { enabled: true, // Start with a low bandwidth estimate (200kbps) so it plays instantly // on slow connections and smoothly scales UP to Hi-Fi if the connection allows. defaultBandwidthEstimate: 100000, switchInterval: 1, // Check more frequently bandwidthDowngradeTarget: 0.8, // Downgrade more aggressively if bandwidth drops restrictToElementSize: false, }, mediaSource: { codecSwitchingStrategy: 'smooth', }, }); this.shakaPlayer.addEventListener('adaptation', this.updateAdaptiveQualityBadge.bind(this)); this.shakaPlayer.addEventListener('variantchanged', this.updateAdaptiveQualityBadge.bind(this)); this.shakaInitialized = false; // Monitor and bridge different codec groups (e.g. AAC to FLAC) since native ABR isolates them setInterval(this.evaluateCrossCodecAbr.bind(this), 3000); } else { console.error('Browser not supported for Shaka Player'); } this.loadQueueState(); await this.setupMediaSession(); this.radioEnabled = radioSettings.isEnabled(); this.radioSeeds = []; this.isFetchingRadio = false; this.radioFetchPromise = null; this.playbackSequence = 0; window.addEventListener('beforeunload', async () => { await this.saveQueueState(); }); // Handle visibility change for iOS - AudioContext gets suspended when screen locks document.addEventListener('visibilitychange', async () => { const el = this.activeElement; if (document.visibilityState === 'visible' && !el.paused) { // Ensure audio context is resumed when user returns to the app if (!audioContextManager.isReady()) { audioContextManager.init(el); } await audioContextManager.resume(); } if (document.visibilityState === 'visible' && this.autoplayBlocked) { this.autoplayBlocked = false; el.play().catch(() => {}); } }); this._setupVideoSync(); } _setupVideoSync() { if (!this.video || !this.audio) return; const eventsToSync = ['timeupdate', 'seeking', 'seeked', 'volumechange']; eventsToSync.forEach((eventName) => { this.video.addEventListener(eventName, (e) => { if (this.currentTrack?.type === 'video') { if (eventName === 'timeupdate' || eventName === 'seeking' || eventName === 'seeked') { try { if (this.video.readyState >= 2 && (this.audio.readyState > 0 || this.audio.src)) { this.audio.currentTime = this.video.currentTime; } } catch { // Video-to-audio time sync may fail if readyState is stale } } const syncedEvent = new Event(eventName, { bubbles: e.bubbles, cancelable: e.cancelable }); this.audio.dispatchEvent(syncedEvent); } }); }); } setVolume(value) { this.userVolume = Math.max(0, Math.min(1, value)); localStorage.setItem('volume', this.userVolume); this.applyReplayGain(); } applyReplayGain() { const mode = replayGainSettings.getMode(); // 'off', 'track', 'album' let gainDb = 0; let peak = 1.0; if (mode !== 'off' && this.currentRgValues) { const { trackReplayGain, trackPeakAmplitude, albumReplayGain, albumPeakAmplitude } = this.currentRgValues; if (mode === 'album' && albumReplayGain !== undefined) { gainDb = albumReplayGain; peak = albumPeakAmplitude || 1.0; } else if (trackReplayGain !== undefined) { gainDb = trackReplayGain; peak = trackPeakAmplitude || 1.0; } // Apply Pre-Amp gainDb += replayGainSettings.getPreamp(); } // Convert dB to linear scale: 10^(dB/20) let scale = Math.pow(10, gainDb / 20); // Peak protection (prevent clipping) if (scale * peak > 1.0) { scale = 1.0 / peak; } // Apply exponential volume curve if enabled const curvedVolume = exponentialVolumeSettings.applyCurve(this.userVolume); // Calculate effective volume const effectiveVolume = curvedVolume * scale; const el = this.activeElement; // Apply to audio element and/or Web Audio graph const isApple = isIos || isSafari; if (audioContextManager.isReady() && !isApple) { // If Web Audio is active, we apply volume there for better compatibility // Especially on Linux where audio.volume might not affect the Web Audio graph el.volume = 1.0; audioContextManager.setVolume(effectiveVolume); } else { // Safari bypasses WebAudio for HLS, so we MUST set el.volume directly to reflect ReplayGain if (audioContextManager.isReady()) { audioContextManager.setVolume(1.0); // Reset graph gain if it somehow routes } el.volume = Math.max(0, Math.min(1, effectiveVolume)); } } applyAudioEffects() { const speed = audioEffectsSettings.getSpeed(); const el = this.activeElement; if (el.playbackRate !== speed) { el.playbackRate = speed; } const preservePitch = audioEffectsSettings.isPreservePitchEnabled(); if (el.preservesPitch !== preservePitch) { el.preservesPitch = preservePitch; // Firefox support if (el.mozPreservesPitch !== undefined) { el.mozPreservesPitch = preservePitch; } } } setPlaybackSpeed(speed) { const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0)); audioEffectsSettings.setSpeed(validSpeed); this.applyAudioEffects(); } setPreservePitch(enabled) { audioEffectsSettings.setPreservePitch(enabled); this.applyAudioEffects(); } loadQueueState() { const savedState = queueManager.getQueue(); if (savedState) { this.queue = savedState.queue || []; this.shuffledQueue = savedState.shuffledQueue || []; this.originalQueueBeforeShuffle = savedState.originalQueueBeforeShuffle || []; this.currentQueueIndex = savedState.currentQueueIndex ?? -1; this.shuffleActive = savedState.shuffleActive || false; this.repeatMode = savedState.repeatMode !== undefined ? savedState.repeatMode : REPEAT_MODE.OFF; // Restore current track if queue exists and index is valid const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (this.currentQueueIndex >= 0 && this.currentQueueIndex < currentQueue.length) { this.currentTrack = currentQueue[this.currentQueueIndex]; // Restore UI const track = this.currentTrack; const trackTitle = getTrackTitle(track); const trackArtistsHTML = getTrackArtistsHTML(track); const yearDisplay = getTrackYearDisplay(track); const coverEl = document.querySelector('.now-playing-bar .cover'); const titleEl = document.querySelector('.now-playing-bar .title'); const albumEl = document.querySelector('.now-playing-bar .album'); const artistEl = document.querySelector('.now-playing-bar .artist'); if (coverEl) { const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null; const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover); if (videoCoverUrl) { if (coverEl.tagName === 'IMG') { const video = document.createElement('video'); video.src = videoCoverUrl; video.autoplay = true; video.loop = true; video.muted = true; video.playsInline = true; video.className = coverEl.className; video.id = coverEl.id; video.style.objectFit = 'cover'; coverEl.replaceWith(video); } else if (coverEl.tagName === 'VIDEO' && coverEl.src !== videoCoverUrl) { coverEl.src = videoCoverUrl; } } else { if (coverEl.tagName === 'VIDEO') { const img = document.createElement('img'); img.src = coverUrl; img.className = coverEl.className; img.id = coverEl.id; coverEl.replaceWith(img); } else { coverEl.src = coverUrl; } } } if (titleEl) { const qualityBadge = createQualityBadgeHTML(track); titleEl.innerHTML = `${escapeHtml(trackTitle)} ${qualityBadge}`; } if (albumEl) { const albumTitle = track.album?.title || ''; if (albumTitle && albumTitle !== trackTitle) { albumEl.textContent = albumTitle; albumEl.style.display = 'block'; } else { albumEl.textContent = ''; albumEl.style.display = 'none'; } } if (artistEl) artistEl.innerHTML = trackArtistsHTML + yearDisplay; // Fetch album release date in background if missing if (!yearDisplay && track.album?.id) { this.loadAlbumYear(track, trackArtistsHTML, artistEl); } const mixBtn = document.getElementById('now-playing-mix-btn'); if (mixBtn) { mixBtn.style.display = track.mixes && track.mixes.TRACK_MIX ? 'flex' : 'none'; } const totalDurationEl = document.getElementById('total-duration'); if (totalDurationEl) totalDurationEl.textContent = formatTime(track.duration); document.title = `${trackTitle} • ${getTrackArtists(track)}`; this.updatePlayingTrackIndicator(); this.updateMediaSession(track); } } } async saveQueueState() { queueManager.saveQueue({ queue: this.queue, shuffledQueue: this.shuffledQueue, originalQueueBeforeShuffle: this.originalQueueBeforeShuffle, currentQueueIndex: this.currentQueueIndex, shuffleActive: this.shuffleActive, repeatMode: this.repeatMode, }); if (window.renderQueueFunction) { await window.renderQueueFunction(); } } async setupMediaSession() { if (!('mediaSession' in navigator)) return; const setHandlers = async () => { navigator.mediaSession.setActionHandler('play', async () => { const el = this.activeElement; // Initialize and resume audio context first (required for iOS lock screen) // Must happen before audio.play() or audio won't route through Web Audio if (!audioContextManager.isReady()) { audioContextManager.init(el); this.applyReplayGain(); } await audioContextManager.resume(); try { await el.play(); } catch (e) { console.error('MediaSession play failed:', e); // If play fails, try to handle it like a regular play/pause await this.handlePlayPause(); } }); navigator.mediaSession.setActionHandler('pause', () => { this.activeElement.pause(); }); navigator.mediaSession.setActionHandler('previoustrack', async () => { // Ensure audio context is active for iOS lock screen controls if (!audioContextManager.isReady()) { audioContextManager.init(this.activeElement); this.applyReplayGain(); } await audioContextManager.resume(); this.playPrev(); }); navigator.mediaSession.setActionHandler('nexttrack', async () => { // Ensure audio context is active for iOS lock screen controls if (!audioContextManager.isReady()) { audioContextManager.init(this.activeElement); this.applyReplayGain(); } await audioContextManager.resume(); await this.playNext(); }); if (!this.isIOS) { 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.activeElement.currentTime = Math.max(0, details.seekTime); this.updateMediaSessionPositionState(); } }); navigator.mediaSession.setActionHandler('stop', () => { this.activeElement.pause(); this.activeElement.currentTime = 0; this.updateMediaSessionPlaybackState(); }); }; if (this.isIOS) { // iOS: set handlers only when playback starts. Setting them in the constructor makes // the lock screen show +10/-10. Registering on first 'playing' gives next/previous track this.audio.addEventListener('playing', () => setHandlers(), { once: true }); if (this.video) { this.video.addEventListener('playing', () => setHandlers(), { once: true }); } } else { await setHandlers(); } } 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 } of tracksToPreload) { if (this.preloadCache.has(track.id)) continue; const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-')); const isPodcast = track.isPodcast || (track.id && String(track.id).startsWith('podcast_')); if (track.isLocal || isTracker || isPodcast || (track.audioUrl && !track.isLocal)) continue; try { const streamInfo = track.type == 'video' ? await this.api.getVideoStreamUrl(track.id) : await this.api.getStreamUrl(track.id, this.quality); if (this.preloadAbortController.signal.aborted) break; 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( () => {} ); } } catch (error) { if (error.name !== 'AbortError') { // console.debug('Failed to get stream URL for preload:', trackTitle); } } } } async setupHlsVideo(video, result, fallbackImg) { const url = result.videoUrl || result.hlsUrl || result; const Hls = (await import('hls.js')).default; if (!url) return; if (this.hls) { this.hls.destroy(); this.hls = null; } const qualityBtn = document.getElementById('fs-quality-btn'); const qualityMenu = document.getElementById('fs-quality-menu'); if (qualityBtn) qualityBtn.style.display = 'none'; if (qualityMenu) qualityMenu.style.display = 'none'; if (typeof url === 'string' && (url.includes('.m3u8') || url.includes('application/vnd.apple.mpegurl'))) { if (Hls.isSupported()) { this.hls = new Hls(); this.hls.loadSource(url); this.hls.attachMedia(video); this.hls.on(Hls.Events.MANIFEST_PARSED, async () => { video.play().catch(() => {}); await this.setupVideoQualitySelector(); }); this.hls.on(Hls.Events.ERROR, (_event, data) => { if (data.fatal) { console.warn('HLS fatal error:', data.type); if (fallbackImg) video.replaceWith(fallbackImg); this.hls.destroy(); this.hls = null; } }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; } else { if (fallbackImg) video.replaceWith(fallbackImg); } } else { video.src = url; video.onerror = async () => { if (result && result.hlsUrl) { await this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, fallbackImg); } else if (fallbackImg) { video.replaceWith(fallbackImg); } }; } } async setupVideoQualitySelector() { if (!this.hls || !this.hls.levels || this.hls.levels.length === 0) return; const Hls = (await import('hls.js')).default; const qualityBtn = document.getElementById('fs-quality-btn'); const qualityMenu = document.getElementById('fs-quality-menu'); if (!qualityBtn || !qualityMenu) return; const levels = this.hls.levels; const qualityLabels = [ 'Auto', ...levels.map((level) => { const height = level.height || 0; const bandwidth = level.bitrate || 0; if (height >= 1080) return '1080p'; if (height >= 720) return '720p'; if (height >= 480) return '480p'; if (height >= 360) return '360p'; if (height >= 180) return '180p'; return `${Math.round(bandwidth / 1000)}k`; }), ]; const updateQualityMenu = () => { const currentLevel = this.hls.currentLevel; qualityMenu.innerHTML = qualityLabels .map((label, i) => { const isActive = currentLevel === i - 1 || (i === 0 && currentLevel === -1); return ``; }) .join(''); qualityMenu.querySelectorAll('.fs-quality-option').forEach((btn) => { btn.onclick = (e) => { e.stopPropagation(); const level = parseInt(btn.dataset.level); this.hls.currentLevel = level; const labelSpan = qualityBtn.querySelector('.fs-quality-label'); if (labelSpan) labelSpan.textContent = level === -1 ? 'Auto' : qualityLabels[level + 1] || 'Auto'; qualityMenu.style.display = 'none'; }; }); }; qualityBtn.style.display = 'flex'; qualityBtn.onclick = (e) => { e.stopPropagation(); const isVisible = qualityMenu.style.display === 'block'; qualityMenu.style.display = isVisible ? 'none' : 'block'; if (!isVisible) { updateQualityMenu(); } }; this.hls.on(Hls.Events.LEVEL_SWITCHED, () => { updateQualityMenu(); const labelSpan = qualityBtn.querySelector('.fs-quality-label'); if (labelSpan) { const currentLevel = this.hls.currentLevel; labelSpan.textContent = currentLevel === -1 ? 'Auto' : qualityLabels[currentLevel + 1] || 'Auto'; } }); document.addEventListener('click', () => { qualityMenu.style.display = 'none'; }); qualityMenu.onclick = (e) => e.stopPropagation(); } async playVideo(video) { if (!video) return; const videoTrack = { ...video, type: 'video', artist: video.artist || (video.artists && video.artists[0]) || 'Unknown Artist', album: video.album || { title: 'Video', cover: video.image || video.cover }, }; await this.setQueue([videoTrack], 0); await this.playTrackFromQueue(); } async playTrackFromQueue(startTime = 0, recursiveCount = 0, isRetry = false) { if (!isRetry) { this.isFallbackRetry = false; } const currentSequence = ++this.playbackSequence; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) { return; } const track = currentQueue[this.currentQueueIndex]; if (track.isUnavailable) { console.warn(`Attempted to play unavailable track: ${track.title}. Skipping...`); await this.playNext(); return; } // Check if track is blocked const { contentBlockingSettings } = await import('./storage.js'); if (contentBlockingSettings.shouldHideTrack(track)) { console.warn(`Attempted to play blocked track: ${track.title}. Skipping...`); await this.playNext(); return; } // Proactively fetch more artist tracks when the last track starts playing console.log('[playTrackFromQueue] Check for fetch:', { radioEnabled: this.radioEnabled, artistId: this.artistPopularTracksState.artistId, hasMore: this.artistPopularTracksState.hasMore, isFetching: this.artistPopularTracksState.isFetching, currentIndex: this.currentQueueIndex, queueLength: currentQueue.length, isLastTrack: this.currentQueueIndex >= currentQueue.length - 1, }); if ( !this.radioEnabled && this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore && !this.artistPopularTracksState.isFetching && this.currentQueueIndex >= currentQueue.length - 1 ) { console.log('[playTrackFromQueue] Fetching more tracks!'); await this.fetchMoreArtistPopularTracks().then(async (newTracks) => { console.log('[playTrackFromQueue] Got tracks:', newTracks?.length); if (newTracks && newTracks.length > 0) { await this.addToQueue(newTracks); } }); } await this.saveQueueState(); this.currentTrack = track; const trackTitle = getTrackTitle(track); const trackArtistsHTML = getTrackArtistsHTML(track); const yearDisplay = getTrackYearDisplay(track); const trackInfo = document.querySelector('.now-playing-bar .track-info'); const coverEl = trackInfo?.querySelector('.cover:not(#audio-player):not(#video-player)'); const isVideoTrack = track.type === 'video'; const activeElement = isVideoTrack ? this.video : this.audio; const inactiveElement = isVideoTrack ? this.audio : this.video; if (this.hls) { this.hls.destroy(); this.hls = null; } if (this.shakaInitialized && this.shakaPlayer) { this.shakaPlayer.unload(); this.shakaInitialized = false; } if (inactiveElement) { inactiveElement.pause(); inactiveElement.src = ''; inactiveElement.removeAttribute('src'); inactiveElement.style.display = 'none'; if (inactiveElement.parentElement !== document.body) { document.body.appendChild(inactiveElement); } } if (activeElement) { activeElement.pause(); activeElement.src = ''; activeElement.removeAttribute('src'); } audioContextManager.changeSource(activeElement); if (isVideoTrack) { if (coverEl) coverEl.style.display = 'none'; if (this.video) { const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex'; if (!isInFullscreen) { this.video.style.display = 'block'; this.video.className = 'cover video-cover-mirror'; this.video.style.width = '56px'; this.video.style.height = '56px'; this.video.style.borderRadius = 'var(--radius-sm)'; this.video.style.objectFit = 'cover'; this.video.style.gridArea = 'none'; this.video.muted = false; if (trackInfo && this.video.parentElement !== trackInfo) { trackInfo.insertBefore(this.video, trackInfo.firstChild); } } } } else { if (coverEl) { coverEl.style.display = 'block'; const coverUrl = this.api.getCoverUrl(track.image || track.cover || track.album?.cover); if (coverEl.src !== coverUrl) coverEl.src = coverUrl; } if (this.audio) { const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex'; if (!isInFullscreen) { this.audio.style.display = 'none'; } } } document.querySelector('.now-playing-bar .title').innerHTML = `${escapeHtml(trackTitle)} ${createQualityBadgeHTML(track)}`; const albumEl = document.querySelector('.now-playing-bar .album'); if (albumEl) { const albumTitle = track.album?.title || ''; if (albumTitle && albumTitle !== trackTitle) { albumEl.textContent = albumTitle; albumEl.style.display = 'block'; } else { albumEl.textContent = ''; albumEl.style.display = 'none'; } } const artistEl = document.querySelector('.now-playing-bar .artist'); artistEl.innerHTML = trackArtistsHTML + yearDisplay; // Fetch album release date in background if missing if (!yearDisplay && track.album?.id) { this.loadAlbumYear(track, trackArtistsHTML, artistEl); } const mixBtn = document.getElementById('now-playing-mix-btn'); if (mixBtn) { mixBtn.style.display = track.mixes && track.mixes.TRACK_MIX ? 'flex' : 'none'; } document.title = `${trackTitle} • ${getTrackArtists(track)}`; this.updatePlayingTrackIndicator(); this.updateMediaSession(track); this.updateMediaSessionPlaybackState(); try { let streamUrl; const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-')); const isPodcast = track.isPodcast || (track.id && String(track.id).startsWith('podcast_')); if (isPodcast) { streamUrl = track.enclosureUrl; if (!streamUrl) { console.warn(`Podcast episode ${trackTitle} audio URL is missing. Skipping.`); track.isUnavailable = true; await this.playNext(); return; } if (this.playbackSequence !== currentSequence) return; this.currentRgValues = null; this.applyReplayGain(); activeElement.src = streamUrl; this.applyAudioEffects(); const canPlay = await this.waitForCanPlayOrTimeout(activeElement); if (!canPlay || this.playbackSequence !== currentSequence) return; if (startTime > 0) { activeElement.currentTime = startTime; } const played = await this.safePlay(activeElement); if (!played) return; } else if (isTracker || (track.audioUrl && !track.isLocal)) { streamUrl = track.audioUrl; if ( (!streamUrl || (typeof streamUrl === 'string' && streamUrl.startsWith('blob:'))) && track.remoteUrl ) { streamUrl = track.remoteUrl; } if (!streamUrl) { console.warn(`Track ${trackTitle} audio URL is missing. Skipping.`); track.isUnavailable = true; await this.playNext(); return; } if (isTracker && !streamUrl.startsWith('blob:') && streamUrl.startsWith('http')) { try { const response = await fetch(streamUrl); if (response.ok) { const blob = await response.blob(); streamUrl = URL.createObjectURL(blob); } } catch (e) { console.warn('Failed to fetch tracker blob, trying direct link', e); } } if (this.playbackSequence !== currentSequence) return; this.currentRgValues = null; this.applyReplayGain(); activeElement.src = streamUrl; this.applyAudioEffects(); // Wait for audio to be ready before playing (prevents restart issues with blob URLs) const canPlay = await this.waitForCanPlayOrTimeout(activeElement); if (!canPlay || this.playbackSequence !== currentSequence) return; if (startTime > 0) { activeElement.currentTime = startTime; } const played = await this.safePlay(activeElement); if (!played) return; } else if (track.isLocal && track.file) { streamUrl = URL.createObjectURL(track.file); if (this.playbackSequence !== currentSequence) return; this.currentRgValues = null; // No replaygain for local files yet this.applyReplayGain(); activeElement.src = streamUrl; this.applyAudioEffects(); // 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; } const played = await this.safePlay(activeElement); if (!played) return; } else if (track.type === 'video') { if (UIRenderer.instance) { const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex'; if (!isInFullscreen) { const lyricsManager = UIRenderer.instance.lyricsManager; UIRenderer.instance.showFullscreenCover( track, this.getNextTrack(), lyricsManager, activeElement ); } } streamUrl = await this.api.getVideoStreamUrl(track.id); if (this.playbackSequence !== currentSequence) return; if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) { await this.setupHlsVideo(activeElement, streamUrl, null); } else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) { await this.shakaPlayer.attach(activeElement); await this.shakaPlayer.load(streamUrl); this.shakaInitialized = true; const savedAdaptiveQuality = localStorage.getItem('adaptive-playback-quality') || 'auto'; this.forceQuality(savedAdaptiveQuality); this.updateAdaptiveQualityBadge(); } else { activeElement.src = streamUrl; } this.applyAudioEffects(); const canPlay = await this.waitForCanPlayOrTimeout(activeElement); if (!canPlay || this.playbackSequence !== currentSequence) return; if (startTime > 0) { activeElement.currentTime = startTime; } await this.safePlay(activeElement); } else { // Tidal: Try to get ReplayGain from manifest first, supplement with track info if needed const streamInfoPromise = this.preloadCache.has(track.id) ? Promise.resolve(this.preloadCache.get(track.id)) : this.api.getStreamUrl(track.id, this.quality); // We only need the legacy track info if we missed getting ReplayGain from the manifest endpoint const resolvedStreamInfo = await streamInfoPromise; if (this.playbackSequence !== currentSequence) return; streamUrl = resolvedStreamInfo.url; if (resolvedStreamInfo.rgInfo) { this.currentRgValues = resolvedStreamInfo.rgInfo; this.applyReplayGain(); } else { // Fallback to legacy metadata if manifest lacked normalization data const trackData = await this.api.getTrack(track.id, this.quality).catch(() => null); if (this.playbackSequence !== currentSequence) return; if (trackData && trackData.info) { this.currentRgValues = { trackReplayGain: trackData.info.trackReplayGain, trackPeakAmplitude: trackData.info.trackPeakAmplitude, albumReplayGain: trackData.info.albumReplayGain, albumPeakAmplitude: trackData.info.albumPeakAmplitude, }; } else { this.currentRgValues = null; } this.applyReplayGain(); } if (this.playbackSequence !== currentSequence) return; // 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); } this.shakaInitialized = true; this.applyAudioEffects(); const savedAdaptiveQuality = localStorage.getItem('adaptive-playback-quality') || 'auto'; this.forceQuality(savedAdaptiveQuality); this.updateAdaptiveQualityBadge(); const canPlay = await this.waitForCanPlayOrTimeout(activeElement); if (!canPlay || this.playbackSequence !== currentSequence) return; 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; } const played = await this.safePlay(activeElement); if (!played) return; } } void this.preloadNextTracks().catch(console.error); } catch (error) { if (this.playbackSequence !== currentSequence) return; if (error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) { this.autoplayBlocked = true; return; } if (this.quality === 'HI_RES_LOSSLESS' && !this.isFallbackRetry) { this.isFallbackRetry = true; const originalQuality = this.quality; this.quality = 'LOSSLESS'; this.isFallbackInProgress = true; try { await this.playTrackFromQueue(startTime, recursiveCount, true); return; } catch { // LOSSLESS fallback also failed - fall through to error handling below } finally { this.quality = originalQuality; this.isFallbackRetry = false; this.isFallbackInProgress = false; } return; } 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); } } } async playAtIndex(index) { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (index >= 0 && index < currentQueue.length) { this.currentQueueIndex = index; await this.playTrackFromQueue(0, 0); } } async playNext(recursiveCount = 0) { const currentQueue = this.getCurrentQueue(); const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1; if (recursiveCount > currentQueue.length) { if (this.radioEnabled && isLastTrack) { this.fetchRadioRecommendations().then(async () => { const updatedQueue = this.getCurrentQueue(); if (this.currentQueueIndex < updatedQueue.length - 1) { await this.playNext(0); } }); return; } if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) { await this.fetchMoreArtistPopularTracks().then(async (newTracks) => { if (newTracks && newTracks.length > 0) { await this.addToQueue(newTracks); await this.playNext(0); } else { this.activeElement.pause(); } }); return; } this.activeElement.pause(); return; } import('./storage.js') .then(async ({ contentBlockingSettings }) => { if ( this.repeatMode === REPEAT_MODE.ONE && !currentQueue[this.currentQueueIndex]?.isUnavailable && !contentBlockingSettings.shouldHideTrack(currentQueue[this.currentQueueIndex]) ) { await this.playTrackFromQueue(0, recursiveCount); return; } if (!isLastTrack) { this.currentQueueIndex++; const track = currentQueue[this.currentQueueIndex]; if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) { return this.playNext(recursiveCount + 1); } } else if (this.radioEnabled) { this.fetchRadioRecommendations().then(async () => { const updatedQueue = this.getCurrentQueue(); if (this.currentQueueIndex < updatedQueue.length - 1) { await this.playNext(0); } }); return; } else if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) { await this.fetchMoreArtistPopularTracks().then(async (newTracks) => { if (newTracks && newTracks.length > 0) { await this.addToQueue(newTracks); } // Now play the next track (which is now at currentQueueIndex + 1 if tracks were added) this.currentQueueIndex++; await this.playTrackFromQueue(0, recursiveCount); }); return; } else if (this.repeatMode === REPEAT_MODE.ALL) { this.currentQueueIndex = 0; const track = currentQueue[this.currentQueueIndex]; if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) { return this.playNext(recursiveCount + 1); } } else { return; } await this.playTrackFromQueue(0, recursiveCount); }) .catch(console.error); } async enableRadio(seeds = []) { this.radioEnabled = true; radioSettings.setEnabled(true); if (seeds.length === 0) { await this.wipeQueue(); const pickedSeeds = await this.pickRadioSeeds(); if (pickedSeeds.length > 0) { this.radioSeeds = pickedSeeds; const initialQueue = [...pickedSeeds].sort(() => 0.5 - Math.random()).slice(0, 5); await this.setQueue(initialQueue, 0, true); await this.playAtIndex(0); } } else { this.radioSeeds = Array.isArray(seeds) ? seeds : [seeds]; await this.wipeQueue(); const initialQueue = Array.isArray(seeds) ? seeds.slice(0, 5) : [seeds]; await this.setQueue(initialQueue, 0, true); await this.playAtIndex(0); } const currentQueue = this.getCurrentQueue(); if (this.currentQueueIndex >= currentQueue.length - 2) { await this.fetchRadioRecommendations(); } window.dispatchEvent(new CustomEvent('radio-state-changed', { detail: { enabled: true } })); } disableRadio() { if (!this.radioEnabled) return; this.radioEnabled = false; radioSettings.setEnabled(false); window.dispatchEvent(new CustomEvent('radio-state-changed', { detail: { enabled: false } })); } fetchRadioRecommendations() { if (this.isFetchingRadio) return this.radioFetchPromise || Promise.resolve(); this.isFetchingRadio = true; this.showRadioLoading(true); this.radioFetchPromise = (async () => { try { if (this.radioSeeds.length === 0) { this.radioSeeds = await this.pickRadioSeeds(); } const shuffledSeeds = [...this.radioSeeds].sort(() => 0.5 - Math.random()); const seeds = shuffledSeeds.length > 0 ? shuffledSeeds.slice(0, 5) : this.currentTrack ? [this.currentTrack] : []; if (seeds.length === 0) { return; } const [favorites, userPlaylists, history] = await Promise.all([ db.getFavorites('track'), db.getAll('user_playlists'), db.getHistory(), ]); const knownTrackIds = new Set([ ...favorites.map((t) => t.id), ...userPlaylists.flatMap((p) => (p.tracks || []).map((t) => t.id)), ...history.map((t) => t.id), ]); const recommendations = await this.api.getRecommendedTracksForPlaylist(seeds, 20, { knownTrackIds: knownTrackIds, }); if (recommendations && recommendations.length > 0) { const currentQueueIds = new Set(this.getCurrentQueue().map((t) => t.id)); let newTracks = recommendations.filter((t) => { return !currentQueueIds.has(t.id); }); if (newTracks.length > 0) { const tracksToAdd = newTracks.sort(() => 0.5 - Math.random()).slice(0, 5); await this.addToQueue(tracksToAdd); } } } catch (error) { console.error('Failed to fetch radio recommendations:', error); } finally { this.isFetchingRadio = false; this.radioFetchPromise = null; setTimeout(() => this.showRadioLoading(false), 500); } })(); return this.radioFetchPromise; } async pickRadioSeeds() { try { const [history, favorites, userPlaylists] = await Promise.all([ db.getHistory(), db.getFavorites('track'), db.getAll('user_playlists'), ]); let potentialSeeds = []; if (history && history.length > 0) { const frequencyMap = new Map(); history.forEach((t) => { frequencyMap.set(t.id, (frequencyMap.get(t.id) || 0) + 1); }); const historyTracks = Array.from(new Set(history.map((t) => t.id))) .map((id) => history.find((t) => t.id === id)) .sort((a, b) => frequencyMap.get(b.id) - frequencyMap.get(a.id)); potentialSeeds.push(...historyTracks.slice(0, 20)); } if (favorites && favorites.length > 0) { potentialSeeds.push(...favorites); } if (userPlaylists && userPlaylists.length > 0) { userPlaylists.forEach((p) => { if (p.tracks && p.tracks.length > 0) { const randomTracks = p.tracks.sort(() => 0.5 - Math.random()).slice(0, 5); potentialSeeds.push(...randomTracks); } }); } if (potentialSeeds.length === 0) return []; const uniqueSeeds = Array.from(new Set(potentialSeeds.map((s) => s.id))).map((id) => potentialSeeds.find((s) => s.id === id) ); return uniqueSeeds.sort(() => 0.5 - Math.random()).slice(0, 50); } catch (error) { console.error('Failed to pick radio seeds:', error); return this.currentTrack ? [this.currentTrack] : []; } } showRadioLoading(show) { const loadingEl = document.getElementById('radio-loading-indicator'); if (loadingEl) { loadingEl.style.display = show ? 'flex' : 'none'; } } playPrev(recursiveCount = 0) { const el = this.activeElement; if (el.currentTime > 3) { el.currentTime = 0; this.updateMediaSessionPositionState(); } else if (this.currentQueueIndex > 0) { this.currentQueueIndex--; // Skip unavailable and blocked tracks const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (recursiveCount > currentQueue.length) { console.error('All tracks in queue are unavailable or blocked.'); el.pause(); return; } import('./storage.js') .then(async ({ contentBlockingSettings }) => { const track = currentQueue[this.currentQueueIndex]; if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) { return this.playPrev(recursiveCount + 1); } await this.playTrackFromQueue(0, recursiveCount); }) .catch(console.error); } } get activeElement() { return this.currentTrack?.type === 'video' ? this.video : this.audio; } async handlePlayPause() { const el = this.activeElement; const hasSource = el.src || el.currentSrc || el.srcObject || this.shakaInitialized; if (!hasSource || el.error) { if (this.currentTrack) { await this.playTrackFromQueue(0, 0); } return; } if (el.paused) { this.safePlay(el).catch(async (e) => { if (e.name === 'NotAllowedError' || e.name === 'AbortError') return; console.error('Play failed, reloading track:', e); if (this.currentTrack) { await this.playTrackFromQueue(0, 0); } }); } else { el.pause(); await this.saveQueueState(); } } seekBackward(seconds = 10) { const el = this.activeElement; const newTime = Math.max(0, el.currentTime - seconds); el.currentTime = newTime; this.updateMediaSessionPositionState(); } seekForward(seconds = 10) { const el = this.activeElement; const duration = el.duration || 0; const newTime = Math.min(duration, el.currentTime + seconds); el.currentTime = newTime; this.updateMediaSessionPositionState(); } async toggleShuffle() { this.shuffleActive = !this.shuffleActive; if (this.shuffleActive) { this.originalQueueBeforeShuffle = [...this.queue]; const currentTrack = this.queue[this.currentQueueIndex]; const tracksToShuffle = [...this.queue]; if (currentTrack && this.currentQueueIndex >= 0) { tracksToShuffle.splice(this.currentQueueIndex, 1); } for (let i = tracksToShuffle.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracksToShuffle[i], tracksToShuffle[j]] = [tracksToShuffle[j], tracksToShuffle[i]]; } if (currentTrack) { this.shuffledQueue = [currentTrack, ...tracksToShuffle]; this.currentQueueIndex = 0; } else { this.shuffledQueue = tracksToShuffle; this.currentQueueIndex = -1; } } else { const currentTrack = this.shuffledQueue[this.currentQueueIndex]; this.queue = [...this.originalQueueBeforeShuffle]; this.currentQueueIndex = this.queue.findIndex((t) => t.id === currentTrack?.id); } this.preloadCache.clear(); void this.preloadNextTracks().catch(console.error); await this.saveQueueState(); } async toggleRepeat() { this.repeatMode = (this.repeatMode + 1) % 3; await this.saveQueueState(); return this.repeatMode; } async setQueue(tracks, startIndex = 0, isRadio = false) { if (!isRadio) { this.disableRadio(); } this.queue = tracks; this.currentQueueIndex = startIndex; this.shuffleActive = false; this.preloadCache.clear(); await this.saveQueueState(); } setArtistPopularTracksContext(artistId, initialTracks, offset = 15, hasMore = true) { this.artistPopularTracksState = { artistId, offset, initialTracks, isFetching: false, hasMore, }; } clearArtistPopularTracksContext() { this.artistPopularTracksState = { artistId: null, offset: 0, initialTracks: [], isFetching: false, hasMore: false, }; } async fetchMoreArtistPopularTracks() { const state = this.artistPopularTracksState; console.log('[fetchMoreArtistPopularTracks] Called:', { artistId: state.artistId, offset: state.offset, isFetching: state.isFetching, hasMore: state.hasMore, }); if (!state.artistId || state.isFetching || !state.hasMore) { console.log('[fetchMoreArtistPopularTracks] Early return'); return []; } state.isFetching = true; try { console.log('[fetchMoreArtistPopularTracks] Fetching with offset:', state.offset); const result = await this.api.getArtistTopTracks(state.artistId, { offset: state.offset, limit: 15, firstTrackId: state.initialTracks[0]?.id, }); console.log('[fetchMoreArtistPopularTracks] Result:', result); if (result.tracks && result.tracks.length > 0) { state.offset += result.tracks.length; state.hasMore = result.hasMore; return result.tracks; } else { state.hasMore = false; return []; } } catch (error) { console.warn('Failed to fetch more artist popular tracks:', error); state.hasMore = false; return []; } finally { state.isFetching = false; } } async addToQueue(trackOrTracks) { const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks]; this.queue.push(...tracks); if (this.shuffleActive) { this.shuffledQueue.push(...tracks); this.originalQueueBeforeShuffle.push(...tracks); } if (!this.currentTrack || this.currentQueueIndex === -1) { this.currentQueueIndex = this.getCurrentQueue().length - tracks.length; await this.playTrackFromQueue(0, 0); } await this.saveQueueState(); } async addNextToQueue(trackOrTracks) { const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks]; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const insertIndex = this.currentQueueIndex + 1; // Insert after current track currentQueue.splice(insertIndex, 0, ...tracks); // If we are shuffling, we might want to also add it to the original queue for consistency, // though syncing that is tricky. The standard logic often just appends to the active queue view. if (this.shuffleActive) { this.originalQueueBeforeShuffle.push(...tracks); // Sync original queue } await this.saveQueueState(); void this.preloadNextTracks().catch(console.error); // Update preload since next track changed } async removeFromQueue(index) { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; // If removing current track if (index === this.currentQueueIndex) { // If playing, we might want to stop or just let it finish? // For now, let's just remove it. // If it's the last track, playback will stop naturally or we handle it? } if (index < this.currentQueueIndex) { this.currentQueueIndex--; } const removedTrack = currentQueue.splice(index, 1)[0]; if (this.shuffleActive) { // Also remove from original queue const originalIndex = this.originalQueueBeforeShuffle.findIndex((t) => t.id === removedTrack.id); // Simple ID check if (originalIndex !== -1) { this.originalQueueBeforeShuffle.splice(originalIndex, 1); } } await this.saveQueueState(); void this.preloadNextTracks().catch(console.error); } async clearQueue() { if (this.currentTrack) { this.queue = [this.currentTrack]; if (this.shuffleActive) { this.shuffledQueue = [this.currentTrack]; this.originalQueueBeforeShuffle = [this.currentTrack]; } else { this.shuffledQueue = []; this.originalQueueBeforeShuffle = []; } this.currentQueueIndex = 0; } else { this.queue = []; this.shuffledQueue = []; this.originalQueueBeforeShuffle = []; this.currentQueueIndex = -1; } this.preloadCache.clear(); await this.saveQueueState(); } async wipeQueue() { const el = this.activeElement; el.pause(); el.src = ''; this.currentTrack = null; this.queue = []; this.shuffledQueue = []; this.originalQueueBeforeShuffle = []; this.currentQueueIndex = -1; await this.saveQueueState(); if (UIRenderer.instance) { UIRenderer.instance.setCurrentTrack(null); } if (window.renderQueueFunction) { await window.renderQueueFunction(); } } async 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++; } await this.saveQueueState(); } getCurrentQueue() { return this.shuffleActive ? this.shuffledQueue : this.queue; } getNextTrack() { const currentQueue = this.getCurrentQueue(); if (this.currentQueueIndex === -1 || currentQueue.length === 0) return null; const nextIndex = this.currentQueueIndex + 1; if (nextIndex < currentQueue.length) { return currentQueue[nextIndex]; } else if (this.repeatMode === REPEAT_MODE.ALL) { return currentQueue[0]; } return null; } loadAlbumYear(track, trackArtistsHTML, artistEl) { if (!trackDateSettings.useAlbumYear()) return; this.api .getAlbum(track.album.id) .then(({ album }) => { if (album?.releaseDate && this.currentTrack?.id === track.id) { track.album.releaseDate = album.releaseDate; const year = new Date(album.releaseDate).getFullYear(); if (!isNaN(year) && artistEl) { artistEl.innerHTML = `${trackArtistsHTML} • ${year}`; } } }) .catch(() => {}); } updatePlayingTrackIndicator() { const currentTrack = this.getCurrentQueue()[this.currentQueueIndex]; document.querySelectorAll('.track-item').forEach((item) => { item.classList.toggle('playing', currentTrack && item.dataset.trackId == currentTrack.id); }); document.querySelectorAll('.queue-track-item').forEach((item) => { const index = parseInt(item.dataset.queueIndex); item.classList.toggle('playing', index === this.currentQueueIndex); }); } updateAdaptiveQualityBadge() { if (!this.currentTrack) return; try { const titleEl = document.querySelector('.now-playing-bar .title'); if (!titleEl) return; let badgeEl = titleEl.querySelector('.shaka-quality-badge'); // Determine if the track is inherently an Atmos track based on metadata const trackBaseQuality = deriveTrackQuality(this.currentTrack); const isTrackAtmos = trackBaseQuality === 'DOLBY_ATMOS' || this.currentTrack?.audioQuality === 'DOLBY_ATMOS'; if (this.shakaInitialized) { const variants = this.shakaPlayer.getVariantTracks(); const activeVariant = variants.find((t) => t.active); if (activeVariant) { if (!badgeEl) { badgeEl = document.createElement('span'); badgeEl.className = 'quality-badge quality-hires shaka-quality-badge'; badgeEl.title = 'Adaptive Stream Quality'; titleEl.appendChild(badgeEl); const staticBadge = titleEl.querySelector('.quality-badge:not(.shaka-quality-badge)'); if (staticBadge) staticBadge.style.display = 'none'; } let text = ''; let isAtmosPlaying = false; if (activeVariant.videoBandwidth && activeVariant.height) { text = `${activeVariant.height}p`; } else if (activeVariant.audioCodec) { const codec = activeVariant.audioCodec.toLowerCase(); if (codec.includes('flac')) { const sampleRate = activeVariant.audioSamplingRate ? activeVariant.audioSamplingRate / 1000 : 44.1; if (sampleRate > 48 || activeVariant.audioBandwidth > 1200000) { text = `HD 24/${sampleRate}`; } else { text = 'FLAC'; } } else if (codec.includes('mp4a')) { text = 'AAC'; } else if (codec.includes('ec-3') || codec.includes('ac-3')) { if (codec.includes('joc') || codec === 'ec-3') { isAtmosPlaying = true; } else { text = 'Dolby'; } } else { text = activeVariant.audioCodec; } if ( activeVariant.audioBandwidth && !text.includes('FLAC') && !text.includes('HD') && !isAtmosPlaying ) { text += ` ${Math.round(activeVariant.audioBandwidth / 1000)}k`; } } else { text = 'Auto'; } if (isAtmosPlaying) { badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge'; badgeEl.innerHTML = SVG_ATMOS(20); } else { badgeEl.className = 'quality-badge quality-hires shaka-quality-badge'; badgeEl.textContent = text; } badgeEl.style.display = text || isAtmosPlaying ? 'inline-flex' : 'none'; } } else if ( (isIos || isSafari) && this.activeElement && this.activeElement.src && (this.activeElement.src.includes('.m3u8') || this.currentTrack) ) { if (!badgeEl) { badgeEl = document.createElement('span'); badgeEl.className = 'quality-badge quality-hires shaka-quality-badge'; badgeEl.title = 'HLS Stream Quality'; titleEl.appendChild(badgeEl); const staticBadge = titleEl.querySelector('.quality-badge:not(.shaka-quality-badge)'); if (staticBadge) staticBadge.style.display = 'none'; } let text = ''; // Ensure device can actually decode Atmos before rendering logo for HLS let deviceSupportsAtmos = false; try { if (window.MediaSource && typeof window.MediaSource.isTypeSupported === 'function') { deviceSupportsAtmos = MediaSource.isTypeSupported('audio/mp4; codecs="ec-3"') || MediaSource.isTypeSupported('audio/mp4; codecs="eac3"'); } if (!deviceSupportsAtmos && typeof document !== 'undefined') { const a = document.createElement('audio'); deviceSupportsAtmos = !!( a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"') ); } } catch { // Atmos codec detection may fail on some browsers } let isAtmosPlaying = isTrackAtmos && deviceSupportsAtmos; const q = this.quality || localStorage.getItem('adaptive-playback-quality') || 'auto'; if (!isAtmosPlaying) { if (q === 'HI_RES_LOSSLESS') text = 'HD FLAC'; else if (q === 'LOSSLESS') text = 'FLAC'; else if (q === 'HIGH') text = 'AAC'; else if (q === 'LOW') text = 'AAC Low'; else if (q === 'auto') text = 'HLS Auto'; else text = 'HLS'; } if (isAtmosPlaying) { badgeEl.innerHTML = SVG_ATMOS(20); badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge'; } else { badgeEl.textContent = text; badgeEl.className = 'quality-badge quality-hires shaka-quality-badge'; } badgeEl.style.display = 'inline-flex'; } else { if (badgeEl) badgeEl.style.display = 'none'; } } catch (e) { console.error('Failed to update adaptive quality badge', e); } } evaluateCrossCodecAbr() { if (!this.shakaInitialized || !this.shakaPlayer || this.shakaPlayer.isBuffering() || this.activeElement.paused) return; try { const stats = this.shakaPlayer.getStats(); const estimatedBandwidth = stats.estimatedBandwidth; if (!estimatedBandwidth) return; const variants = this.shakaPlayer.getVariantTracks(); if (variants.length < 2) return; const activeVariant = variants.find((v) => v.active); if (!activeVariant) return; // Sort variants by bandwidth descending const sortedVariants = [...variants].sort((a, b) => b.bandwidth - a.bandwidth); const safeUpBandwidth = estimatedBandwidth * 0.85; let bestVariant = sortedVariants[0]; for (const variant of sortedVariants) { if (variant.bandwidth <= safeUpBandwidth) { bestVariant = variant; break; } } if (sortedVariants[sortedVariants.length - 1].bandwidth > safeUpBandwidth) { bestVariant = sortedVariants[sortedVariants.length - 1]; } if (bestVariant.audioCodec !== activeVariant.audioCodec && bestVariant.id !== activeVariant.id) { // To safely cross AdaptationSet boundaries in Shaka, explicitly select the track this.shakaPlayer.configure({ preferredAudioCodecs: [bestVariant.audioCodec] }); this.shakaPlayer.selectVariantTrack(bestVariant, false, 0); // false = don't clear buffer, smooth transition // Re-enable ABR so it can dynamically downgrade within that new codec family if needed this.shakaPlayer.configure({ abr: { enabled: true } }); } } catch { // fail silently on abr checks } } forceQuality(quality) { if (!this.shakaInitialized || !this.shakaPlayer) return; try { if (quality === 'auto') { this.shakaPlayer.configure({ abr: { enabled: true }, preferredAudioCodecs: [], }); return; } const variants = this.shakaPlayer.getVariantTracks(); if (variants.length === 0) return; let bestVariant = variants[0]; if (quality === 'LOW' || quality === 'HIGH') { const targetBandwidth = quality === 'LOW' ? 96000 : 320000; const aacVariants = variants.filter((v) => v.audioCodec && v.audioCodec.toLowerCase().includes('mp4a')); const searchVariants = aacVariants.length > 0 ? aacVariants : variants; let minDiff = Infinity; for (const variant of searchVariants) { const bw = variant.audioBandwidth || variant.bandwidth; const diff = Math.abs(bw - targetBandwidth); if (diff < minDiff) { minDiff = diff; bestVariant = variant; } } } else if (quality === 'LOSSLESS' || quality === 'HI_RES_LOSSLESS') { const flacVariants = variants.filter( (v) => v.audioCodec && v.audioCodec.toLowerCase().includes('flac') ); if (flacVariants.length > 0) { if (quality === 'HI_RES_LOSSLESS') { // Find highest quality FLAC bestVariant = flacVariants.reduce((prev, current) => { const prevBw = prev.audioBandwidth || prev.bandwidth || 0; const currBw = current.audioBandwidth || current.bandwidth || 0; return currBw > prevBw ? current : prev; }, flacVariants[0]); } else { // Find standard lossless (lowest bandwidth FLAC, usually 16-bit 44.1kHz) bestVariant = flacVariants.reduce((prev, current) => { const prevBw = prev.audioBandwidth || prev.bandwidth || 0; const currBw = current.audioBandwidth || current.bandwidth || 0; return currBw < prevBw ? current : prev; }, flacVariants[0]); } } else { // Fallback to highest overall bestVariant = variants.reduce((prev, current) => { const prevBw = prev.audioBandwidth || prev.bandwidth || 0; const currBw = current.audioBandwidth || current.bandwidth || 0; return currBw > prevBw ? current : prev; }, variants[0]); } } this.shakaPlayer.configure({ abr: { enabled: false } }); if (bestVariant.audioCodec) { this.shakaPlayer.configure({ preferredAudioCodecs: [bestVariant.audioCodec] }); } this.shakaPlayer.selectVariantTrack(bestVariant, false, 0); // false = don't clear buffer, smooth transition } catch (e) { console.error('Failed to force quality', e); } } updateMediaSession(track) { if (!('mediaSession' in navigator)) return; // Force a refresh for picky Bluetooth systems by clearing metadata first navigator.mediaSession.metadata = null; const coverId = track.album?.cover; const trackTitle = getTrackTitle(track); navigator.mediaSession.metadata = new MediaMetadata({ title: trackTitle || 'Unknown Title', artist: getTrackArtists(track) || 'Unknown Artist', album: track.album?.title || 'Unknown Album', artwork: coverId ? [ { src: this.api.getCoverUrl(coverId, '1280'), sizes: '1280x1280', type: 'image/jpeg', }, ] : undefined, }); this.updateMediaSessionPlaybackState(); this.updateMediaSessionPositionState(); } updateMediaSessionPlaybackState() { if (!('mediaSession' in navigator)) return; navigator.mediaSession.playbackState = this.activeElement.paused ? 'paused' : 'playing'; } updateMediaSessionPositionState() { if (!('mediaSession' in navigator)) return; if (!('setPositionState' in navigator.mediaSession)) return; const el = this.activeElement; const duration = el.duration; if (!duration || isNaN(duration) || !isFinite(duration)) { return; } try { navigator.mediaSession.setPositionState({ duration: duration, playbackRate: el.playbackRate || 1, position: Math.min(el.currentTime, duration), }); } catch (error) { console.log('Failed to update Media Session position:', error); } } async safePlay(element = this.activeElement) { try { await element.play(); this.autoplayBlocked = false; return true; } catch (error) { if (error && (error.name === 'NotAllowedError' || error.name === 'AbortError')) { this.autoplayBlocked = true; return false; } throw error; } } async waitForCanPlayOrTimeout(element = this.activeElement, timeoutMs = 10000) { if (element.readyState >= 2) { return true; } return await new Promise((resolve, reject) => { const onCanPlay = () => { element.removeEventListener('canplay', onCanPlay); element.removeEventListener('error', onError); resolve(true); }; const onError = (e) => { element.removeEventListener('canplay', onCanPlay); element.removeEventListener('error', onError); reject(e); }; element.addEventListener('canplay', onCanPlay); element.addEventListener('error', onError); // Timeout after 10 seconds. Treat as autoplay blocked when backgrounded (esp. iOS PWA). setTimeout(() => { element.removeEventListener('canplay', onCanPlay); element.removeEventListener('error', onError); if (document.visibilityState === 'hidden' || (this.isIOS && this.isPwa)) { this.autoplayBlocked = true; resolve(false); return; } reject(new Error('Timeout waiting for audio to load')); }, timeoutMs); }); } // Sleep Timer Methods setSleepTimer(minutes) { this.clearSleepTimer(); // Clear any existing timer this.sleepTimerEndTime = Date.now() + minutes * 60 * 1000; this.sleepTimer = setTimeout( () => { this.activeElement.pause(); this.clearSleepTimer(); this.updateSleepTimerUI(); }, minutes * 60 * 1000 ); // Update UI every second this.sleepTimerInterval = setInterval(() => { this.updateSleepTimerUI(); }, 1000); this.updateSleepTimerUI(); } clearSleepTimer() { if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; } if (this.sleepTimerInterval) { clearInterval(this.sleepTimerInterval); this.sleepTimerInterval = null; } this.sleepTimerEndTime = null; this.updateSleepTimerUI(); } getSleepTimerRemaining() { if (!this.sleepTimerEndTime) return null; const remaining = Math.max(0, this.sleepTimerEndTime - Date.now()); return Math.ceil(remaining / 1000); // Return seconds remaining } isSleepTimerActive() { return this.sleepTimer !== null; } updateSleepTimerUI() { const timerBtn = document.getElementById('sleep-timer-btn'); const timerBtnDesktop = document.getElementById('sleep-timer-btn-desktop'); const updateBtn = (btn) => { if (!btn) return; if (this.isSleepTimerActive()) { const remaining = this.getSleepTimerRemaining(); if (remaining > 0) { const minutes = Math.floor(remaining / 60); const seconds = remaining % 60; btn.innerHTML = `${minutes}:${seconds.toString().padStart(2, '0')}`; btn.title = `Sleep Timer: ${minutes}:${seconds.toString().padStart(2, '0')} remaining`; btn.classList.add('active'); btn.style.color = 'var(--primary)'; } else { btn.innerHTML = SVG_CLOCK(20); btn.title = 'Sleep Timer'; btn.classList.remove('active'); btn.style.color = ''; } } else { btn.innerHTML = SVG_CLOCK(20); btn.title = 'Sleep Timer'; btn.classList.remove('active'); btn.style.color = ''; } }; updateBtn(timerBtn); updateBtn(timerBtnDesktop); } }