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 "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&#39;";
}
});
}
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()
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论