Music Players — Android Media Session API — etc …
| /*≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈ | |
| Music Player Management | |
| ≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈*/ | |
| const AUDIO_CDN_BASE = "https://pub-54216af4fb1549ff95a6cb5f8d63fe2d.r2.dev"; | |
| // Storage location for all audio files | |
| function readConfig(path, fallback) { | |
| try { | |
| const parts = String(path).split("."); | |
| let node = Config; | |
| for (let i = 0; i < parts.length; i++) { | |
| if (node == null) return fallback; | |
| node = node[parts[i]]; | |
| } | |
| return node === undefined || node === null ? fallback : node; | |
| } catch (err) { | |
| return fallback; | |
| } | |
| } | |
| function escapeHtml(value) { | |
| const str = value == null ? "" : String(value); | |
| try { | |
| if (typeof Utils !== "undefined" && Utils && typeof Utils.esc === "function") { | |
| const out = Utils.esc(str); | |
| if (typeof out === "string") return out; | |
| } | |
| } catch (err) {} | |
| return str.replace(/[&<>"']/g, (ch) => { | |
| switch (ch) { | |
| case "&": | |
| return "&"; | |
| case "<": | |
| return "<"; | |
| case ">": | |
| return ">"; | |
| case '"': | |
| return """; | |
| default: | |
| return "'"; | |
| } | |
| }); | |
| } | |
| function escapeAttr(value) { | |
| return escapeHtml(value); | |
| } | |
| function safeMediaUrl(value, fallback) { | |
| const str = typeof value === "string" ? value.trim() : ""; | |
| if (!str) return fallback; | |
| if (/^data:image\//i.test(str)) return str; | |
| if (/^https?:\/\//i.test(str)) return str; | |
| if (/^blob:/i.test(str)) return str; | |
| if (/^[a-z][a-z0-9+.-]*:/i.test(str)) return fallback; | |
| return str; | |
| } | |
| function clampNumber(value, min, max, fallback) { | |
| const num = Number(value); | |
| if (!Number.isFinite(num)) return fallback; | |
| return Math.min(max, Math.max(min, num)); | |
| } | |
| class PlayerState { | |
| constructor() { | |
| this.currentSong = null; | |
| this.queue = []; | |
| this.queueIndex = -1; | |
| this.isPlaying = false; | |
| this.isBuffering = false; | |
| this.buffered = 0; | |
| this.currentTime = 0; | |
| this.duration = 0; | |
| this.volume = clampNumber(readConfig("VOLUME.default", 1), 0, 1, 1); | |
| this.isMuted = false; | |
| this.playbackRate = 1; | |
| this.repeatMode = "off"; | |
| this.isShuffled = false; | |
| this.recentlyPlayed = []; | |
| this.isDrawerOpen = false; | |
| this.isQueueOpen = false; | |
| this.isLyricsOpen = false; | |
| this.isVideoOpen = false; | |
| this.videoInfo = null; | |
| this.sleepTimerEndsAt = null; | |
| this.sleepTimerId = null; | |
| this.sleepTimerTrackEnd = false; | |
| this.sleepTimerRemaining = null; | |
| this.audioError = null; | |
| this.pendingDeepLinkSong = null; | |
| this.favoriteSongs = []; | |
| this.favoriteArtists = []; | |
| this.favoriteAlbums = []; | |
| this.favoritePlaylists = []; | |
| this.playlists = []; | |
| this.enrichedLibrary = []; | |
| this.favoritesTab = "songs"; | |
| this.selectedPlaylistName = null; | |
| this.selectedPlaylistId = null; | |
| this.isCreatingPlaylist = false; | |
| this.editingPlaylistId = null; | |
| this.artistId = null; | |
| this.selectedAlbumId = null; | |
| this.artistPageName = null; | |
| this.selectedAlbumName = null; | |
| this.currentPage = "home"; | |
| this.isSearchOpen = false; | |
| this.searchQuery = ""; | |
| this.is404 = false; | |
| this.queueSource = null; | |
| this._persist = null; | |
| this._playCounts = new Map(); | |
| this._recentCache = []; | |
| this._originalQueue = null; | |
| this._songIndex = null; | |
| this._albumIndex = null; | |
| this._artistIndex = null; | |
| this._indexedLibrary = null; | |
| this.lastVolume = 1; | |
| } | |
| invalidateLibraryIndex() { | |
| this._songIndex = null; | |
| this._albumIndex = null; | |
| this._artistIndex = null; | |
| this._indexedLibrary = null; | |
| } | |
| _buildIndexes() { | |
| if (this._indexedLibrary === this.enrichedLibrary && this._songIndex) return; | |
| const songs = new Map(); | |
| const albums = new Map(); | |
| const artists = new Map(); | |
| const library = Array.isArray(this.enrichedLibrary) ? this.enrichedLibrary : []; | |
| for (let i = 0; i < library.length; i++) { | |
| const artist = library[i]; | |
| if (!artist) continue; | |
| if (artist.id != null) artists.set(String(artist.id), artist); | |
| if (artist.artist) artists.set(String(artist.artist), artist); | |
| const artistAlbums = Array.isArray(artist.albums) ? artist.albums : []; | |
| for (let a = 0; a < artistAlbums.length; a++) { | |
| const album = artistAlbums[a]; | |
| if (!album) continue; | |
| if (album.id != null) { | |
| albums.set(String(album.id), { | |
| ...album, | |
| artistId: artist.id, | |
| artistName: artist.artist, | |
| artist: artist.artist, | |
| }); | |
| } | |
| const albumSongs = Array.isArray(album.songs) ? album.songs : []; | |
| for (let s = 0; s < albumSongs.length; s++) { | |
| const song = albumSongs[s]; | |
| if (!song || song.id == null) continue; | |
| songs.set(String(song.id), { | |
| ...song, | |
| artistId: artist.id, | |
| albumId: album.id, | |
| artist: artist.artist, | |
| album: album.album, | |
| coverUrl: album.coverUrl, | |
| }); | |
| } | |
| } | |
| } | |
| this._songIndex = songs; | |
| this._albumIndex = albums; | |
| this._artistIndex = artists; | |
| this._indexedLibrary = this.enrichedLibrary; | |
| } | |
| getSongById(id) { | |
| if (id == null) return null; | |
| this._buildIndexes(); | |
| return this._songIndex.get(String(id)) || null; | |
| } | |
| getArtistById(id) { | |
| if (id == null) return null; | |
| this._buildIndexes(); | |
| return this._artistIndex.get(String(id)) || null; | |
| } | |
| getAlbumById(id) { | |
| if (id == null) return null; | |
| this._buildIndexes(); | |
| return this._albumIndex.get(String(id)) || null; | |
| } | |
| getAllSongs() { | |
| this._buildIndexes(); | |
| return Array.from(this._songIndex.values()); | |
| } | |
| getSongsByArtistId(artistId) { | |
| const artist = this.getArtistById(artistId); | |
| if (!artist) return []; | |
| const out = []; | |
| const albums = Array.isArray(artist.albums) ? artist.albums : []; | |
| for (let a = 0; a < albums.length; a++) { | |
| const album = albums[a]; | |
| const songs = Array.isArray(album.songs) ? album.songs : []; | |
| for (let s = 0; s < songs.length; s++) { | |
| out.push({ | |
| ...songs[s], | |
| artistId: artist.id, | |
| albumId: album.id, | |
| artist: artist.artist, | |
| album: album.album, | |
| coverUrl: album.coverUrl, | |
| }); | |
| } | |
| } | |
| return out; | |
| } | |
| getSongsByAlbumId(albumId) { | |
| const album = this.getAlbumById(albumId); | |
| if (!album) return []; | |
| const songs = Array.isArray(album.songs) ? album.songs : []; | |
| return songs.map((song) => ({ | |
| ...song, | |
| artistId: album.artistId, | |
| albumId: album.id, | |
| artist: album.artistName, | |
| album: album.album, | |
| coverUrl: album.coverUrl, | |
| })); | |
| } | |
| buildPlaylistQueue(playlistId) { | |
| const playlists = Array.isArray(this.playlists) ? this.playlists : []; | |
| const pl = playlists.find((p) => String(p.id) === String(playlistId)); | |
| if (!pl || !Array.isArray(pl.songs)) return []; | |
| return pl.songs.map((id) => this.getSongById(id)).filter(Boolean); | |
| } | |
| getPlayCount(songId) { | |
| return this._playCounts.get(String(songId)) || 0; | |
| } | |
| getMostPlayed(limit = 10) { | |
| const size = clampNumber(limit, 1, 500, 10); | |
| const entries = Array.from(this._playCounts.entries()) | |
| .sort((a, b) => b[1] - a[1]) | |
| .slice(0, size); | |
| return entries.map((entry) => this.getSongById(entry[0])).filter(Boolean); | |
| } | |
| getRecentlyPlayed(limit = 20) { | |
| const size = clampNumber(limit, 1, 200, 20); | |
| return this.recentlyPlayed.slice(0, size); | |
| } | |
| isCurrentSong(songId) { | |
| return !!this.currentSong && String(this.currentSong.id) === String(songId); | |
| } | |
| formatTime(seconds) { | |
| try { | |
| return Utils.fmtTime(seconds); | |
| } catch (err) { | |
| const total = Number(seconds); | |
| if (!Number.isFinite(total) || total < 0) return "0:00"; | |
| const mins = Math.floor(total / 60); | |
| const secs = Math.floor(total % 60); | |
| return `${mins}:${secs < 10 ? "0" : ""}${secs}`; | |
| } | |
| } | |
| persist() { | |
| try { | |
| if (this._persist && typeof this._persist.save === "function") this._persist.save(); | |
| } catch (err) {} | |
| } | |
| showToast(message, type = "info", duration) { | |
| try { | |
| window.popups?.toast({ message, type, duration }); | |
| } catch (err) {} | |
| } | |
| modalOpen(content) { | |
| try { | |
| window.popups?.modal({ content, closable: true, autoClose: false }); | |
| } catch (err) {} | |
| } | |
| modalClose() { | |
| try { | |
| window.popups?.closeType("modal"); | |
| } catch (err) {} | |
| } | |
| } | |
| class AudioEngine { | |
| constructor(state) { | |
| this.state = state; | |
| this.audio = new Audio(); | |
| this.audio.preload = "metadata"; | |
| this.audio.volume = clampNumber(state.volume, 0, 1, 1); | |
| this.audio.playbackRate = clampNumber(state.playbackRate, 0.25, 4, 1); | |
| this.mediaSessionManager = null; | |
| this._listeners = new Map(); | |
| this._sourceCandidates = []; | |
| this._sourceIndex = 0; | |
| this._autoplayPending = false; | |
| this._preloader = null; | |
| this._destroyed = false; | |
| this._boundHandlers = []; | |
| if (!this.state._playCounts) this.state._playCounts = new Map(); | |
| if (!this.state._originalQueue) this.state._originalQueue = null; | |
| this._init(); | |
| } | |
| _bind(target, type, handler, options) { | |
| if (!target) return; | |
| target.addEventListener(type, handler, options); | |
| this._boundHandlers.push([target, type, handler, options]); | |
| } | |
| _init() { | |
| this._bind(this.audio, "loadstart", () => { | |
| this.state.duration = 0; | |
| this.state.currentTime = 0; | |
| this.state.buffered = 0; | |
| this._setBuffering(true); | |
| }); | |
| this._bind(this.audio, "play", () => { | |
| this.state.isPlaying = true; | |
| this._autoplayPending = false; | |
| this._emit("play"); | |
| }); | |
| this._bind(this.audio, "playing", () => { | |
| this.state.isPlaying = true; | |
| this._setBuffering(false); | |
| this._emit("playing"); | |
| }); | |
| this._bind(this.audio, "pause", () => { | |
| this.state.isPlaying = false; | |
| this._setBuffering(false); | |
| this._emit("pause"); | |
| }); | |
| this._bind(this.audio, "ended", () => { | |
| this._emit("ended"); | |
| this.handleEnded(); | |
| }); | |
| this._bind(this.audio, "timeupdate", () => { | |
| this.state.currentTime = this.audio.currentTime; | |
| this._emit("timeupdate"); | |
| }); | |
| this._bind(this.audio, "loadedmetadata", () => { | |
| this.state.duration = this.audio.duration; | |
| this._emit("loadedmetadata"); | |
| }); | |
| this._bind(this.audio, "durationchange", () => { | |
| this.state.duration = this.audio.duration; | |
| this._emit("durationchange"); | |
| }); | |
| this._bind(this.audio, "progress", () => { | |
| this.state.buffered = this._bufferedPercent(); | |
| this._emit("progress"); | |
| }); | |
| this._bind(this.audio, "waiting", () => { | |
| this._setBuffering(true); | |
| }); | |
| this._bind(this.audio, "stalled", () => { | |
| this._setBuffering(true); | |
| }); | |
| this._bind(this.audio, "canplay", () => { | |
| this._setBuffering(false); | |
| }); | |
| this._bind(this.audio, "seeked", () => { | |
| this._emit("seeked"); | |
| }); | |
| this._bind(this.audio, "volumechange", () => { | |
| this.state.isMuted = this.audio.muted || this.audio.volume === 0; | |
| this._emit("volumechange"); | |
| }); | |
| this._bind(this.audio, "ratechange", () => { | |
| this.state.playbackRate = this.audio.playbackRate; | |
| this._emit("ratechange"); | |
| }); | |
| this._bind(this.audio, "error", () => this._handleSourceError()); | |
| } | |
| _setBuffering(value) { | |
| const next = !!value; | |
| if (this.state.isBuffering === next) return; | |
| this.state.isBuffering = next; | |
| this._emit("buffering", next); | |
| } | |
| _bufferedPercent() { | |
| const audio = this.audio; | |
| const duration = audio.duration; | |
| if (!Number.isFinite(duration) || duration <= 0) return 0; | |
| if (!audio.buffered || !audio.buffered.length) return 0; | |
| try { | |
| const end = audio.buffered.end(audio.buffered.length - 1); | |
| return clampNumber((end / duration) * 100, 0, 100, 0); | |
| } catch (err) { | |
| return 0; | |
| } | |
| } | |
| _handleSourceError() { | |
| if (this._destroyed) return; | |
| if (this._sourceIndex < this._sourceCandidates.length - 1) { | |
| this._sourceIndex += 1; | |
| this.audio.src = this._sourceCandidates[this._sourceIndex]; | |
| try { | |
| this.audio.load(); | |
| } catch (err) {} | |
| if (this._autoplayPending) this._safePlay(); | |
| return; | |
| } | |
| this._autoplayPending = false; | |
| this._setBuffering(false); | |
| this.state.audioError = this.state.currentSong ? this.state.currentSong.id : null; | |
| this._emit("error", { songId: this.state.audioError }); | |
| } | |
| _safePlay() { | |
| if (this._destroyed) return; | |
| let promise; | |
| try { | |
| promise = this.audio.play(); | |
| } catch (err) { | |
| this._autoplayPending = false; | |
| this._emit("playerror", err); | |
| return; | |
| } | |
| if (promise && typeof promise.catch === "function") { | |
| promise.catch((err) => { | |
| if (!err || err.name === "AbortError") return; | |
| this._autoplayPending = false; | |
| this._emit("playerror", err); | |
| }); | |
| } | |
| } | |
| on(event, cb) { | |
| if (typeof cb !== "function") return () => {}; | |
| if (!this._listeners.has(event)) this._listeners.set(event, []); | |
| this._listeners.get(event).push(cb); | |
| return () => this.off(event, cb); | |
| } | |
| off(event, cb) { | |
| const list = this._listeners.get(event); | |
| if (!list) return; | |
| const index = list.indexOf(cb); | |
| if (index !== -1) list.splice(index, 1); | |
| } | |
| _emit(event, data) { | |
| const list = this._listeners.get(event); | |
| if (!list || !list.length) return; | |
| const snapshot = list.slice(); | |
| for (let i = 0; i < snapshot.length; i++) { | |
| try { | |
| snapshot[i](data); | |
| } catch (err) { | |
| if (typeof console !== "undefined" && console.error) console.error(err); | |
| } | |
| } | |
| } | |
| setMediaSessionManager(manager) { | |
| this.mediaSessionManager = manager; | |
| } | |
| _buildSources(songId) { | |
| const id = encodeURIComponent(String(songId)); | |
| return [`${AUDIO_CDN_BASE}/${id}.mp3`, `${AUDIO_CDN_BASE}/${id}.webm`]; | |
| } | |
| playSong(song, queue = null, autoplay = true, source = null) { | |
| if (!song || song.id == null || song.id === "") return; | |
| const enriched = this.state.getSongById(song.id); | |
| const track = enriched ? { ...song, ...enriched } : song; | |
| if (Array.isArray(queue) && queue.length) { | |
| let index = queue.findIndex((item) => item && String(item.id) === String(track.id)); | |
| if (index === -1) { | |
| queue.unshift(track); | |
| index = 0; | |
| } | |
| this.state.queue = queue; | |
| this.state.queueIndex = index; | |
| } else { | |
| this.state.queue = [track]; | |
| this.state.queueIndex = 0; | |
| } | |
| this.state.currentSong = track; | |
| this.state.audioError = null; | |
| this.state.duration = 0; | |
| this.state.currentTime = 0; | |
| this.state.buffered = 0; | |
| this.state.queueSource = source || this.state.queueSource; | |
| this._sourceCandidates = this._buildSources(track.id); | |
| this._sourceIndex = 0; | |
| this._autoplayPending = !!autoplay; | |
| try { | |
| this.audio.src = this._sourceCandidates[0]; | |
| this.audio.load(); | |
| } catch (err) { | |
| this.state.audioError = track.id; | |
| this._emit("error", { songId: track.id }); | |
| return; | |
| } | |
| if (autoplay) this._safePlay(); | |
| this._updateRecentlyPlayed(track); | |
| this._updatePlayCount(track.id); | |
| this.mediaSessionManager?.updateMetadata(track); | |
| this._emit("songchange", { song: track, source }); | |
| this._preloadNext(); | |
| if (this.state.isDrawerOpen) window.uiManager?.updateFullPlayer?.(); | |
| } | |
| _preloadNext() { | |
| if (readConfig("PRELOAD_NEXT", true) === false) return; | |
| if (typeof navigator !== "undefined" && navigator.connection && navigator.connection.saveData) return; | |
| const next = this.state.queue[this.state.queueIndex + 1]; | |
| if (!next || next.id == null) return; | |
| const url = this._buildSources(next.id)[0]; | |
| if (!this._preloader) { | |
| try { | |
| this._preloader = new Audio(); | |
| this._preloader.preload = "auto"; | |
| this._preloader.muted = true; | |
| this._preloader.volume = 0; | |
| } catch (err) { | |
| this._preloader = null; | |
| return; | |
| } | |
| } | |
| if (this._preloader.src === url) return; | |
| try { | |
| this._preloader.src = url; | |
| this._preloader.load(); | |
| } catch (err) {} | |
| } | |
| togglePlay() { | |
| if (!this.state.currentSong) return; | |
| if (this.state.isVideoOpen) { | |
| window.uiManager?.videoController?.toggleVideoPlayback?.(); | |
| return; | |
| } | |
| if (this.audio.paused) this._safePlay(); | |
| else this.audio.pause(); | |
| } | |
| play() { | |
| if (!this.state.currentSong) return; | |
| if (this.state.isVideoOpen) { | |
| window.uiManager?.videoController?.toggleVideoPlayback?.(); | |
| return; | |
| } | |
| this._safePlay(); | |
| } | |
| pause() { | |
| this.audio.pause(); | |
| } | |
| skipForward() { | |
| const queue = this.state.queue; | |
| if (!queue.length) return; | |
| let nextIndex = this.state.queueIndex + 1; | |
| if (nextIndex >= queue.length) { | |
| if (this.state.repeatMode === "all") nextIndex = 0; | |
| else { | |
| this._emit("queueend"); | |
| return; | |
| } | |
| } | |
| this.playSong(queue[nextIndex], queue, true, "auto"); | |
| } | |
| skipBack() { | |
| const queue = this.state.queue; | |
| if (!queue.length) return; | |
| const restartThreshold = clampNumber(readConfig("PLAYER.restartThreshold", 3), 0, 30, 3); | |
| if (this.audio.currentTime > restartThreshold && !this.audio.paused) { | |
| this.audio.currentTime = 0; | |
| this._emit("seeked"); | |
| return; | |
| } | |
| let prevIndex = this.state.queueIndex - 1; | |
| if (prevIndex < 0) prevIndex = this.state.repeatMode === "all" ? queue.length - 1 : 0; | |
| this.playSong(queue[prevIndex], queue, true, "auto"); | |
| } | |
| jumpTo(index) { | |
| const queue = this.state.queue; | |
| const idx = clampNumber(index, 0, Math.max(0, queue.length - 1), -1); | |
| if (idx < 0 || !queue[idx]) return; | |
| this.playSong(queue[idx], queue, true, "queue"); | |
| } | |
| addToQueue(song, playNext = false) { | |
| if (!song || song.id == null) return; | |
| const enriched = this.state.getSongById(song.id); | |
| const track = enriched ? { ...song, ...enriched } : song; | |
| if (!this.state.queue.length) { | |
| this.state.queue = [track]; | |
| this.state.queueIndex = 0; | |
| this._emit("queuechange"); | |
| return; | |
| } | |
| if (playNext) { | |
| const insertAt = this.state.queueIndex + 1; | |
| this.state.queue.splice(insertAt, 0, track); | |
| } else { | |
| this.state.queue.push(track); | |
| } | |
| this._emit("queuechange"); | |
| this._preloadNext(); | |
| } | |
| removeFromQueue(index) { | |
| const queue = this.state.queue; | |
| const idx = clampNumber(index, 0, queue.length - 1, -1); | |
| if (idx < 0) return null; | |
| const wasCurrent = idx === this.state.queueIndex; | |
| const removed = queue.splice(idx, 1)[0] || null; | |
| if (wasCurrent) { | |
| if (queue.length) { | |
| const nextIndex = Math.min(idx, queue.length - 1); | |
| this.state.queueIndex = nextIndex; | |
| this.playSong(queue[nextIndex], queue, true, "queue"); | |
| } else { | |
| this.state.queueIndex = -1; | |
| this.state.currentSong = null; | |
| this.audio.pause(); | |
| try { | |
| this.audio.removeAttribute("src"); | |
| this.audio.load(); | |
| } catch (err) {} | |
| this.mediaSessionManager?.clearMetadata?.(); | |
| } | |
| } else if (idx < this.state.queueIndex) { | |
| this.state.queueIndex -= 1; | |
| } | |
| this._emit("queuechange"); | |
| return removed; | |
| } | |
| moveInQueue(from, to) { | |
| const queue = this.state.queue; | |
| if (from < 0 || from >= queue.length || to < 0 || to >= queue.length || from === to) return; | |
| const moved = queue.splice(from, 1)[0]; | |
| queue.splice(to, 0, moved); | |
| if (this.state.queueIndex === from) this.state.queueIndex = to; | |
| else if (from < this.state.queueIndex && to >= this.state.queueIndex) this.state.queueIndex -= 1; | |
| else if (from > this.state.queueIndex && to <= this.state.queueIndex) this.state.queueIndex += 1; | |
| this._emit("queuechange"); | |
| this._preloadNext(); | |
| } | |
| clearQueue(keepCurrent = true) { | |
| const current = this.state.queue[this.state.queueIndex] || this.state.currentSong; | |
| if (keepCurrent && current) { | |
| this.state.queue = [current]; | |
| this.state.queueIndex = 0; | |
| } else { | |
| this.state.queue = []; | |
| this.state.queueIndex = -1; | |
| } | |
| this._emit("queuechange"); | |
| } | |
| setVolume(vol) { | |
| const next = clampNumber(vol, 0, 1, this.state.volume); | |
| this.state.volume = next; | |
| this.state.isMuted = next === 0; | |
| this.audio.muted = false; | |
| this.audio.volume = next; | |
| if (next > 0) this.state.lastVolume = next; | |
| this._emit("volumechange"); | |
| } | |
| toggleMute() { | |
| if (!this.state.isMuted) { | |
| this.state.lastVolume = this.state.volume > 0 ? this.state.volume : this.state.lastVolume || 1; | |
| this.state.isMuted = true; | |
| this.audio.volume = 0; | |
| } else { | |
| const restore = clampNumber(this.state.lastVolume || this.state.volume, 0.01, 1, 1); | |
| this.state.isMuted = false; | |
| this.state.volume = restore; | |
| this.audio.volume = restore; | |
| } | |
| this._emit("volumechange"); | |
| } | |
| toggleShuffle() { | |
| this.state.isShuffled = !this.state.isShuffled; | |
| const queue = this.state.queue; | |
| if (this.state.isShuffled) { | |
| if (!this.state._originalQueue) this.state._originalQueue = queue.slice(); | |
| if (queue.length > 1) { | |
| const current = queue[this.state.queueIndex] || null; | |
| const rest = queue.filter((_, i) => i !== this.state.queueIndex); | |
| let shuffled; | |
| try { | |
| shuffled = Utils.shuffle(rest); | |
| } catch (err) { | |
| shuffled = rest.slice(); | |
| } | |
| this.state.queue = current ? [current, ...shuffled] : shuffled; | |
| this.state.queueIndex = current ? 0 : -1; | |
| } | |
| } else if (this.state._originalQueue) { | |
| const currentSong = this.state.currentSong; | |
| const restored = this.state._originalQueue.slice(); | |
| this.state.queue = restored; | |
| let index = currentSong ? restored.findIndex((s) => String(s.id) === String(currentSong.id)) : -1; | |
| if (index === -1) index = restored.length ? 0 : -1; | |
| this.state.queueIndex = index; | |
| this.state._originalQueue = null; | |
| } | |
| this._emit("shufflechange"); | |
| this._emit("queuechange"); | |
| this._preloadNext(); | |
| } | |
| cycleRepeat() { | |
| const modes = ["off", "all", "one"]; | |
| const idx = modes.indexOf(this.state.repeatMode); | |
| this.state.repeatMode = modes[(idx + 1) % modes.length]; | |
| this._emit("repeatchange"); | |
| } | |
| setRepeatMode(mode) { | |
| if (!["off", "all", "one"].includes(mode)) return; | |
| this.state.repeatMode = mode; | |
| this._emit("repeatchange"); | |
| } | |
| setPlaybackRate(rate) { | |
| const next = clampNumber(rate, 0.25, 4, 1); | |
| this.state.playbackRate = next; | |
| this.audio.playbackRate = next; | |
| this._emit("ratechange"); | |
| } | |
| seekTo(seconds) { | |
| const duration = this.audio.duration; | |
| const target = Number(seconds); | |
| if (!Number.isFinite(target)) return; | |
| const max = Number.isFinite(duration) && duration > 0 ? duration : target; | |
| this.audio.currentTime = clampNumber(target, 0, max, 0); | |
| this._emit("seeked"); | |
| } | |
| seekBy(delta) { | |
| this.seekTo((this.audio.currentTime || 0) + Number(delta || 0)); | |
| } | |
| handleEnded() { | |
| if (this.state.sleepTimerTrackEnd) { | |
| this.state.sleepTimerTrackEnd = false; | |
| this.state.isPlaying = false; | |
| this._setBuffering(false); | |
| this._emit("queueend"); | |
| try { | |
| window.dispatchEvent(new CustomEvent("mybeats:sleep-track-end")); | |
| } catch (err) {} | |
| return; | |
| } | |
| if (this.state.repeatMode === "one") { | |
| this.audio.currentTime = 0; | |
| this._safePlay(); | |
| return; | |
| } | |
| if (this.state.queueIndex < this.state.queue.length - 1) { | |
| this.skipForward(); | |
| return; | |
| } | |
| if (this.state.repeatMode === "all" && this.state.queue.length) { | |
| this.state.queueIndex = 0; | |
| this.playSong(this.state.queue[0], this.state.queue, true, "auto"); | |
| return; | |
| } | |
| this.state.isPlaying = false; | |
| this._emit("queueend"); | |
| } | |
| _updateRecentlyPlayed(song) { | |
| const id = song.id; | |
| const list = this.state.recentlyPlayed; | |
| const existingIndex = list.findIndex((s) => String(s.id) === String(id)); | |
| if (existingIndex !== -1) list.splice(existingIndex, 1); | |
| list.unshift(song); | |
| const max = clampNumber(readConfig("QUEUE.recentMax", 50), 1, 500, 50); | |
| if (list.length > max) list.length = max; | |
| try { | |
| window.dispatchEvent(new CustomEvent("mybeats:recently-played", { detail: { song } })); | |
| } catch (err) {} | |
| } | |
| _updatePlayCount(songId) { | |
| const sid = String(songId); | |
| this.state._playCounts.set(sid, (this.state._playCounts.get(sid) || 0) + 1); | |
| try { | |
| window.dispatchEvent(new CustomEvent("mybeats:play-counts")); | |
| } catch (err) {} | |
| } | |
| restorePlaybackState(song, queue, time, wasPlaying) { | |
| if (!song || song.id == null) return; | |
| const enriched = this.state.getSongById(song.id); | |
| const track = enriched ? { ...song, ...enriched } : song; | |
| const list = Array.isArray(queue) && queue.length ? queue : [track]; | |
| let index = list.findIndex((s) => s && String(s.id) === String(track.id)); | |
| if (index === -1) index = 0; | |
| this.state.currentSong = track; | |
| this.state.queue = list; | |
| this.state.queueIndex = index; | |
| this.state.duration = 0; | |
| this.state.currentTime = 0; | |
| this.state.buffered = 0; | |
| this._sourceCandidates = this._buildSources(track.id); | |
| this._sourceIndex = 0; | |
| this._autoplayPending = !!wasPlaying; | |
| try { | |
| this.audio.src = this._sourceCandidates[0]; | |
| this.audio.load(); | |
| } catch (err) { | |
| this.state.audioError = track.id; | |
| return; | |
| } | |
| const seekTime = Number(time); | |
| if (Number.isFinite(seekTime) && seekTime > 0) { | |
| const applySeek = () => { | |
| try { | |
| this.audio.currentTime = seekTime; | |
| } catch (err) {} | |
| }; | |
| if (this.audio.readyState >= 1) applySeek(); | |
| else this.audio.addEventListener("loadedmetadata", applySeek, { once: true }); | |
| } | |
| this.audio.playbackRate = clampNumber(this.state.playbackRate, 0.25, 4, 1); | |
| this.audio.volume = this.state.isMuted ? 0 : clampNumber(this.state.volume, 0, 1, 1); | |
| if (wasPlaying) this._safePlay(); | |
| this.mediaSessionManager?.updateMetadata(track); | |
| this._preloadNext(); | |
| } | |
| get currentTime() { | |
| return this.audio.currentTime; | |
| } | |
| set currentTime(val) { | |
| this.seekTo(val); | |
| } | |
| get duration() { | |
| return this.audio.duration; | |
| } | |
| set duration(val) { | |
| this.state.duration = val; | |
| } | |
| get paused() { | |
| return this.audio.paused; | |
| } | |
| get readyState() { | |
| return this.audio.readyState; | |
| } | |
| destroy() { | |
| if (this._destroyed) return; | |
| this._destroyed = true; | |
| for (let i = 0; i < this._boundHandlers.length; i++) { | |
| const entry = this._boundHandlers[i]; | |
| try { | |
| entry[0].removeEventListener(entry[1], entry[2], entry[3]); | |
| } catch (err) {} | |
| } | |
| this._boundHandlers.length = 0; | |
| this._listeners.clear(); | |
| try { | |
| this.audio.pause(); | |
| this.audio.removeAttribute("src"); | |
| this.audio.load(); | |
| } catch (err) {} | |
| if (this._preloader) { | |
| try { | |
| this._preloader.src = ""; | |
| } catch (err) {} | |
| this._preloader = null; | |
| } | |
| } | |
| } | |
| class MediaSessionManager { | |
| constructor(state, audioPlayer) { | |
| this.state = state; | |
| this.audioPlayer = audioPlayer; | |
| this.audio = audioPlayer.audio; | |
| this._supported = | |
| typeof navigator !== "undefined" && | |
| "mediaSession" in navigator && | |
| typeof window !== "undefined" && | |
| typeof window.MediaMetadata === "function"; | |
| this._pendingMetadata = null; | |
| this._metadataKey = null; | |
| this._rafId = null; | |
| this._positionUpdateScheduled = false; | |
| this._destroyed = false; | |
| this._onPlay = () => { | |
| this.updatePlaybackState(); | |
| this._reapplyMetadataIfMissing(); | |
| this.updatePositionState(); | |
| }; | |
| this._onPause = () => { | |
| this.updatePlaybackState(); | |
| this.updatePositionState(); | |
| }; | |
| this._onEnded = () => { | |
| this.updatePlaybackState(); | |
| this.updatePositionState(); | |
| }; | |
| this._onTimeUpdate = () => this._schedulePositionUpdate(); | |
| this._onLoadedMetadata = () => { | |
| this.updatePositionState(); | |
| this._reapplyMetadataIfMissing(); | |
| }; | |
| this._onDurationChange = () => this.updatePositionState(); | |
| this._onSeeked = () => this.updatePositionState(); | |
| this._onRateChange = () => this.updatePositionState(); | |
| if (!this._supported) return; | |
| this._setupActionHandlers(); | |
| this._attachAudioListeners(); | |
| } | |
| updateMetadata(songData) { | |
| if (!this._supported || this._destroyed) return; | |
| if (!songData) { | |
| this.clearMetadata(); | |
| return; | |
| } | |
| const song = this._resolveSong(songData); | |
| const title = song.title || song.name || "Unknown Title"; | |
| const artist = song.artist || song.artistName || "Unknown Artist"; | |
| const album = song.album || song.albumName || ""; | |
| const artwork = this._buildArtwork(song.coverUrl); | |
| const key = `${title}|${artist}|${album}|${song.coverUrl || ""}`; | |
| if (key === this._metadataKey && navigator.mediaSession.metadata) { | |
| this.updatePlaybackState(); | |
| this.updatePositionState(); | |
| return; | |
| } | |
| this._pendingMetadata = { title, artist, album, artwork }; | |
| try { | |
| navigator.mediaSession.metadata = new MediaMetadata(this._pendingMetadata); | |
| this._metadataKey = key; | |
| } catch (err) { | |
| try { | |
| navigator.mediaSession.metadata = new MediaMetadata({ title, artist, album }); | |
| this._pendingMetadata = { title, artist, album, artwork: [] }; | |
| this._metadataKey = `${title}|${artist}|${album}|`; | |
| } catch (err2) { | |
| this._metadataKey = null; | |
| } | |
| } | |
| this.updatePlaybackState(); | |
| this.updatePositionState(); | |
| } | |
| clearMetadata() { | |
| if (!this._supported || this._destroyed) return; | |
| this._pendingMetadata = null; | |
| this._metadataKey = null; | |
| try { | |
| navigator.mediaSession.metadata = null; | |
| navigator.mediaSession.playbackState = "none"; | |
| } catch (err) {} | |
| if ("setPositionState" in navigator.mediaSession) { | |
| try { | |
| navigator.mediaSession.setPositionState(); | |
| } catch (err) {} | |
| } | |
| } | |
| updatePlaybackState() { | |
| if (!this._supported || this._destroyed) return; | |
| try { | |
| navigator.mediaSession.playbackState = this.audio.paused ? "paused" : "playing"; | |
| } catch (err) {} | |
| } | |
| updatePositionState() { | |
| if (!this._supported || this._destroyed) return; | |
| if (!("setPositionState" in navigator.mediaSession)) return; | |
| let duration = this.audio.duration; | |
| let position = this.audio.currentTime; | |
| let rate = this.audio.playbackRate; | |
| if (!Number.isFinite(duration) || duration <= 0) return; | |
| if (!Number.isFinite(position) || position < 0) position = 0; | |
| if (position > duration) position = duration; | |
| if (!Number.isFinite(rate) || rate <= 0) rate = 1; | |
| try { | |
| navigator.mediaSession.setPositionState({ | |
| duration: duration, | |
| playbackRate: rate, | |
| position: position, | |
| }); | |
| } catch (err) {} | |
| } | |
| destroy() { | |
| if (!this._supported) return; | |
| this._destroyed = true; | |
| this.audio.removeEventListener("play", this._onPlay); | |
| this.audio.removeEventListener("pause", this._onPause); | |
| this.audio.removeEventListener("ended", this._onEnded); | |
| this.audio.removeEventListener("timeupdate", this._onTimeUpdate); | |
| this.audio.removeEventListener("loadedmetadata", this._onLoadedMetadata); | |
| this.audio.removeEventListener("durationchange", this._onDurationChange); | |
| this.audio.removeEventListener("seeked", this._onSeeked); | |
| this.audio.removeEventListener("ratechange", this._onRateChange); | |
| if (this._rafId) cancelAnimationFrame(this._rafId); | |
| this._rafId = null; | |
| const ms = navigator.mediaSession; | |
| ["play", "pause", "previoustrack", "nexttrack", "seekbackward", "seekforward", "seekto", "stop"].forEach((action) => { | |
| try { | |
| ms.setActionHandler(action, null); | |
| } catch (err) {} | |
| }); | |
| this.clearMetadata(); | |
| } | |
| _resolveSong(song) { | |
| if (!song) return {}; | |
| if (song.title && song.artist && song.coverUrl) return song; | |
| const enriched = this.state?.getSongById?.(song.id); | |
| return enriched ? { ...song, ...enriched } : song; | |
| } | |
| _reapplyMetadataIfMissing() { | |
| if (!this._supported || this._destroyed || !this._pendingMetadata) return; | |
| if (navigator.mediaSession.metadata) return; | |
| try { | |
| navigator.mediaSession.metadata = new MediaMetadata(this._pendingMetadata); | |
| } catch (err) {} | |
| } | |
| _buildArtwork(coverUrl) { | |
| if (!coverUrl || typeof coverUrl !== "string") return []; | |
| let absoluteUrl; | |
| try { | |
| absoluteUrl = new URL(coverUrl, document.baseURI).href; | |
| } catch (err) { | |
| return []; | |
| } | |
| try { | |
| const protocol = new URL(absoluteUrl).protocol; | |
| if (protocol !== "http:" && protocol !== "https:") return []; | |
| } catch (err) { | |
| return []; | |
| } | |
| const sizes = ["96x96", "128x128", "192x192", "256x256", "384x384", "512x512"]; | |
| return sizes.map((size) => ({ src: absoluteUrl, sizes: size, type: "image/jpeg" })); | |
| } | |
| _setupActionHandlers() { | |
| const ms = navigator.mediaSession; | |
| if (!ms) return; | |
| const safeHandler = (action, fn) => { | |
| try { | |
| ms.setActionHandler(action, fn); | |
| } catch (err) {} | |
| }; | |
| safeHandler("play", async () => { | |
| try { | |
| await this.audio.play(); | |
| } catch (err) {} | |
| this.updatePlaybackState(); | |
| }); | |
| safeHandler("pause", () => { | |
| this.audio.pause(); | |
| this.updatePlaybackState(); | |
| }); | |
| safeHandler("previoustrack", () => { | |
| this.audioPlayer.skipBack?.(); | |
| this.updatePlaybackState(); | |
| }); | |
| safeHandler("nexttrack", () => { | |
| this.audioPlayer.skipForward?.(); | |
| this.updatePlaybackState(); | |
| }); | |
| safeHandler("seekbackward", (details) => { | |
| const skip = details && Number.isFinite(details.seekOffset) ? details.seekOffset : 10; | |
| this.audio.currentTime = Math.max(0, this.audio.currentTime - skip); | |
| this.updatePositionState(); | |
| }); | |
| safeHandler("seekforward", (details) => { | |
| const skip = details && Number.isFinite(details.seekOffset) ? details.seekOffset : 10; | |
| const duration = this.audio.duration; | |
| if (!Number.isFinite(duration)) return; | |
| this.audio.currentTime = Math.min(duration, this.audio.currentTime + skip); | |
| this.updatePositionState(); | |
| }); | |
| safeHandler("seekto", (details) => { | |
| if (!details || details.seekTime == null) return; | |
| const duration = this.audio.duration; | |
| if (!Number.isFinite(duration)) return; | |
| if (details.fastSeek && "fastSeek" in this.audio) { | |
| try { | |
| this.audio.fastSeek(details.seekTime); | |
| return; | |
| } catch (err) {} | |
| } | |
| this.audio.currentTime = Math.min(duration, Math.max(0, details.seekTime)); | |
| this.updatePositionState(); | |
| }); | |
| safeHandler("stop", () => { | |
| this.audio.pause(); | |
| this.audio.currentTime = 0; | |
| this.state.isPlaying = false; | |
| this.clearMetadata() |
评论
?
参与讨论