/** * [NMPv2] NeteaseMiniPlayer v2 JavaScript * Lightweight Player Component Based on NetEase Cloud Music API * * Copyright 2025 BHCN STUDIO & 北海的佰川(ImBHCN[numakkiyu]) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ (() => { try { const s = document.currentScript; if (s && s.src) { fetch(s.src, { mode: "cors", credentials: "omit" }).catch(() => {}); } } catch (e) {} })(); const GlobalAudioManager = { currentPlayer: null, setCurrent(player) { if (this.currentPlayer && this.currentPlayer !== player) { this.currentPlayer.pause(); } this.currentPlayer = player; }, }; const ICONS = { prev: ``, next: ``, play: ``, pause: ``, volume: ``, lyrics: ``, list: ``, minimize: ``, maximize: ``, loopList: ``, loopSingle: ``, shuffle: ``, }; class NeteaseMiniPlayer { constructor(element) { this.element = element; this.element.neteasePlayer = this; this.config = this.parseConfig(); this.currentSong = null; this.playlist = []; this.currentIndex = 0; this.audio = new Audio(); this.wasPlayingBeforeHidden = false; this.isPlaying = false; this.currentTime = 0; this.duration = 0; this.volume = 0.7; this.lyrics = []; this.currentLyricIndex = -1; this.showLyrics = this.config.lyric; this.cache = new Map(); this.init(); this.playMode = "list"; this.shuffleHistory = []; this.idleTimeout = null; this.idleDelay = 5000; this.isIdle = false; } parseConfig() { const element = this.element; const position = element.dataset.position || "static"; const validPositions = ["static", "top-left", "top-right", "bottom-left", "bottom-right"]; const finalPosition = validPositions.includes(position) ? position : "static"; const defaultMinimized = element.dataset.defaultMinimized === "true"; const embedValue = element.getAttribute("data-embed") || element.dataset.embed; const isEmbed = embedValue === "true" || embedValue === true; const autoPauseAttr = element.getAttribute("data-auto-pause") ?? element.dataset.autoPause; const autoPauseDisabled = autoPauseAttr === "true" || autoPauseAttr === true; const apiUrls = JSON.parse(element.dataset.apiUrls) || apiUrls === []; // 读取 autoPause 配置 let autoPause = true; // 默认启用自动暂停 if (window.__NETEASE_MUSIC_CONFIG__?.autoPause !== undefined) { autoPause = window.__NETEASE_MUSIC_CONFIG__.autoPause; } else if (element.dataset.autoPause !== undefined) { autoPause = element.dataset.autoPause !== "false"; } return { embed: isEmbed, autoplay: element.dataset.autoplay === "true", playlistId: element.dataset.playlistId, songId: element.dataset.songId, position: finalPosition, lyric: element.dataset.lyric !== "false", theme: element.dataset.theme || "auto", size: element.dataset.size || "compact", defaultMinimized: defaultMinimized, autoPauseDisabled: autoPauseDisabled, autoPause: autoPause, apiUrls: apiUrls, }; } async init() { if (this.config.embed) { this.element.setAttribute("data-embed", "true"); } this.element.setAttribute("data-position", this.config.position); if (this.config.embed) { this.element.classList.add("netease-mini-player-embed"); } this.initTheme(); this.createPlayerHTML(); this.applyResponsiveControls?.(); this.setupEnvListeners?.(); this.bindEvents(); this.setupAudioEvents(); try { if (this.config.embed) { if (this.config.songId) { await this.loadSingleSong(this.config.songId); } else if (this.config.playlistId) { await this.loadPlaylist(this.config.playlistId); this.playlist = [this.playlist[0]]; } } else { if (this.config.playlistId) { await this.loadPlaylist(this.config.playlistId); } else if (this.config.songId) { await this.loadSingleSong(this.config.songId); } } if (this.playlist.length > 0) { await this.loadCurrentSong(); if (this.config.autoplay && !this.config.embed) { this.play(); } } if (this.config.defaultMinimized && !this.config.embed && this.config.position !== "static") { this.toggleMinimize(); } } catch (error) { console.error("播放器初始化失败:", error); this.showError("加载失败,请稍后重试"); } } createPlayerHTML() { this.element.innerHTML = `
专辑封面
加载中...
请稍候
♪ 加载歌词中... ♪
${ !this.config.embed ? `` : "" } ${ !this.config.embed ? `` : "" }
0:00
0:00
${ICONS.volume}
${ICONS.lyrics} ${ !this.config.embed ? `${ICONS.loopList}` : "" } ${ !this.config.embed ? `${ICONS.list}` : "" } ${ !this.config.embed ? `${ICONS.minimize}` : "" }
`; this.elements = { albumCover: this.element.querySelector(".album-cover"), albumCoverContainer: this.element.querySelector(".album-cover-container"), songTitle: this.element.querySelector(".song-title"), songArtist: this.element.querySelector(".song-artist"), lyricsContainer: this.element.querySelector(".lyrics-container"), lyricLine: this.element.querySelector(".lyric-line.original"), lyricTranslation: this.element.querySelector(".lyric-line.translation"), playBtn: this.element.querySelector(".play-btn"), playIcon: this.element.querySelector(".play-icon"), pauseIcon: this.element.querySelector(".pause-icon"), prevBtn: this.element.querySelector(".prev-btn"), nextBtn: this.element.querySelector(".next-btn"), progressContainer: this.element.querySelector(".progress-bar-container"), progressBar: this.element.querySelector(".progress-bar"), currentTime: this.element.querySelector(".current-time"), totalTime: this.element.querySelector(".total-time"), volumeContainer: this.element.querySelector(".volume-container"), volumeSlider: this.element.querySelector(".volume-slider"), volumeBar: this.element.querySelector(".volume-bar"), volumeIcon: this.element.querySelector(".volume-icon"), lyricsBtn: this.element.querySelector(".lyrics-btn"), listBtn: this.element.querySelector(".list-btn"), minimizeBtn: this.element.querySelector(".minimize-btn"), playlistContainer: this.element.querySelector(".playlist-container"), playlistContent: this.element.querySelector(".playlist-content"), }; this.isMinimized = false; this.elements.loopModeBtn = this.element.querySelector(".loop-mode-btn"); } bindEvents() { this.elements.playBtn.addEventListener("click", () => this.togglePlay()); if (this.elements.prevBtn) { this.elements.prevBtn.addEventListener("click", () => this.previousSong()); } if (this.elements.nextBtn) { this.elements.nextBtn.addEventListener("click", () => this.nextSong()); } if (this.elements.loopModeBtn) { this.elements.loopModeBtn.addEventListener("click", () => this.togglePlayMode()); } this.elements.albumCoverContainer.addEventListener("click", () => { if (this.element.classList.contains("minimized")) { this.elements.albumCoverContainer.classList.toggle("expanded"); return; } if (this.currentSong && this.currentSong.id) { const songUrl = `https://music.163.com/song?id=${this.currentSong.id}`; window.open(songUrl, "_blank", "noopener,noreferrer"); } }); let isDragging = false; this.elements.progressContainer.addEventListener("mousedown", (e) => { isDragging = true; this.seekTo(e); }); document.addEventListener("mousemove", (e) => { if (isDragging) { this.seekTo(e); } }); document.addEventListener("mouseup", () => { isDragging = false; }); this.elements.progressContainer.addEventListener("click", (e) => this.seekTo(e)); let isVolumesDragging = false; this.elements.volumeSlider.addEventListener("mousedown", (e) => { isVolumesDragging = true; this.setVolume(e); }); document.addEventListener("mousemove", (e) => { if (isVolumesDragging) { this.setVolume(e); } }); document.addEventListener("mouseup", () => { isVolumesDragging = false; }); this.elements.volumeSlider.addEventListener("click", (e) => this.setVolume(e)); this.elements.lyricsBtn.addEventListener("click", () => this.toggleLyrics()); if (this.elements.listBtn) { this.elements.listBtn.addEventListener("click", () => this.togglePlaylist()); } if (this.elements.minimizeBtn) { this.elements.minimizeBtn.addEventListener("click", () => this.toggleMinimize()); } document.addEventListener("click", (e) => { if ( this.elements.playlistContainer && this.elements.playlistContainer.classList.contains("show") ) { if (!this.element.contains(e.target)) { this.togglePlaylist(false); } } }); if (this.config.position !== "static" && !this.config.embed) { this.setupDragAndDrop(); } // 标签页非激活时自动暂停的处理 if (typeof document.hidden !== "undefined" && this.config.autoPause) { document.addEventListener("visibilitychange", () => { if (document.hidden && this.isPlaying) { this.wasPlayingBeforeHidden = true; this.pause(); } else if (!document.hidden && this.wasPlayingBeforeHidden) { this.play(); this.wasPlayingBeforeHidden = false; } }); } this.element.addEventListener("mouseenter", () => { this.restoreOpacity(); }); this.element.addEventListener("mouseleave", () => { this.startIdleTimer(); }); this.applyIdlePolicyOnInit(); } startIdleTimer() { this.clearIdleTimer(); if (!this.shouldEnableIdleOpacity()) return; this.idleTimeout = setTimeout(() => { this.triggerFadeOut(); }, this.idleDelay); } clearIdleTimer() { if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; } } triggerFadeOut() { if (!this.shouldEnableIdleOpacity()) return; if (this.isIdle) return; this.isIdle = true; this.element.classList.remove("fading-in"); const side = this.getDockSide(); if (side) { this.element.classList.add(`docked-${side}`); } this.element.classList.add("fading-out"); const onEnd = (e) => { if (e.animationName !== "player-fade-out") return; this.element.classList.remove("fading-out"); this.element.classList.add("idle"); this.element.removeEventListener("animationend", onEnd); }; this.element.addEventListener("animationend", onEnd); } restoreOpacity() { this.clearIdleTimer(); const side = this.getDockSide(); const hasDock = side ? this.element.classList.contains(`docked-${side}`) : false; if (hasDock) { const popAnim = side === "right" ? "player-popout-right" : "player-popout-left"; this.element.classList.add(`popping-${side}`); const onPopEnd = (e) => { if (e.animationName !== popAnim) return; this.element.removeEventListener("animationend", onPopEnd); this.element.classList.remove(`popping-${side}`); this.element.classList.remove(`docked-${side}`); if (this.isIdle) { this.isIdle = false; } this.element.classList.remove("idle", "fading-out"); this.element.classList.add("fading-in"); const onEndIn = (ev) => { if (ev.animationName !== "player-fade-in") return; this.element.classList.remove("fading-in"); this.element.removeEventListener("animationend", onEndIn); }; this.element.addEventListener("animationend", onEndIn); }; this.element.addEventListener("animationend", onPopEnd); return; } if (!this.isIdle) return; this.isIdle = false; this.element.classList.remove("idle", "fading-out"); this.element.classList.add("fading-in"); const onEndIn = (ev) => { if (ev.animationName !== "player-fade-in") return; this.element.classList.remove("fading-in"); this.element.removeEventListener("animationend", onEndIn); }; this.element.addEventListener("animationend", onEndIn); } shouldEnableIdleOpacity() { return this.isMinimized === true; } applyIdlePolicyOnInit() { if (!this.shouldEnableIdleOpacity()) { this.clearIdleTimer(); this.isIdle = false; this.element.classList.remove( "idle", "fading-in", "fading-out", "docked-left", "docked-right", "popping-left", "popping-right" ); } } getDockSide() { const pos = this.config.position; if (pos === "top-left" || pos === "bottom-left") return "left"; if (pos === "top-right" || pos === "bottom-right") return "right"; return "right"; } static getUAInfo() { if (NeteaseMiniPlayer._uaCache) return NeteaseMiniPlayer._uaCache; const nav = typeof navigator !== "undefined" ? navigator : {}; const uaRaw = nav.userAgent || ""; const ua = uaRaw.toLowerCase(); const platform = (nav.platform || "").toLowerCase(); const maxTP = nav.maxTouchPoints || 0; const isWeChat = /micromessenger/.test(ua); const isQQ = /(mqqbrowser| qq)/.test(ua); const isInAppWebView = /\bwv\b|; wv/.test(ua) || /version\/\d+.*chrome/.test(ua); const isiPhone = /iphone/.test(ua); const isiPadUA = /ipad/.test(ua); const isIOSLikePad = !isiPadUA && platform.includes("mac") && maxTP > 1; const isiOS = isiPhone || isiPadUA || isIOSLikePad; const isAndroid = /android/.test(ua); const isHarmonyOS = /harmonyos/.test(uaRaw) || /huawei|honor/.test(ua); const isMobileToken = /mobile/.test(ua) || /sm-|mi |redmi|huawei|honor|oppo|vivo|oneplus/.test(ua); const isHarmonyDesktop = isHarmonyOS && !isMobileToken && !isAndroid && !isiOS; const isPWA = (typeof window !== "undefined" && ((window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) || nav.standalone === true)) || false; const isMobile = isiOS || isAndroid || (isHarmonyOS && !isHarmonyDesktop) || isMobileToken || isInAppWebView; const info = { isMobile, isiOS, isAndroid, isHarmonyOS, isHarmonyDesktop, isWeChat, isQQ, isInAppWebView, isPWA, isiPad: isiPadUA || isIOSLikePad, }; NeteaseMiniPlayer._uaCache = info; return info; } applyResponsiveControls() { const env = NeteaseMiniPlayer.getUAInfo(); const shouldHideVolume = !!env.isMobile; this.element.classList.toggle("mobile-env", shouldHideVolume); if (this.elements && this.elements.volumeContainer == null) { this.elements.volumeContainer = this.element.querySelector(".volume-container"); } if (this.elements.volumeContainer) { if (shouldHideVolume) { this.elements.volumeContainer.classList.add("sr-visually-hidden"); this.elements.volumeContainer.setAttribute("aria-hidden", "false"); this.elements.volumeSlider?.setAttribute( "aria-label", "音量控制(移动端隐藏,仅无障碍可见)" ); } else { this.elements.volumeContainer.classList.remove("sr-visually-hidden"); this.elements.volumeContainer.removeAttribute("aria-hidden"); this.elements.volumeSlider?.removeAttribute("aria-label"); } } } setupEnvListeners() { const reapply = () => this.applyResponsiveControls(); if (window.matchMedia) { try { const mq1 = window.matchMedia("(orientation: portrait)"); const mq2 = window.matchMedia("(orientation: landscape)"); mq1.addEventListener?.("change", reapply); mq2.addEventListener?.("change", reapply); } catch (e) { mq1.onchange = reapply; mq2.onchange = reapply; } } else { window.addEventListener("orientationchange", reapply); } window.addEventListener("resize", reapply); } setupAudioEvents() { this.audio.addEventListener("loadedmetadata", () => { this.duration = this.audio.duration; this.updateTimeDisplay(); }); this.audio.addEventListener("timeupdate", () => { this.currentTime = this.audio.currentTime; this.updateProgress(); this.updateLyrics(); this.updateTimeDisplay(); }); this.audio.addEventListener("ended", async () => { await this.nextSong(); }); this.audio.addEventListener("error", async (e) => { console.error("音频播放错误:", e); console.error("错误详情:", { code: e.target.error?.code, message: e.target.error?.message, src: e.target.src, }); this.showError("播放失败,尝试下一首"); setTimeout(async () => { await this.nextSong(); }, 1000); }); this.audio.addEventListener("abort", () => { console.warn("音频加载被中断"); }); this.audio.addEventListener("stalled", () => { console.warn("音频加载停滞"); }); this.audio.addEventListener("canplay", () => { if (this.isPlaying && this.audio.paused) { this.audio.play().catch((e) => console.error("自动播放失败:", e)); } }); this.audio.volume = this.volume; this.updateVolumeDisplay(); } async apiRequest(endpoint, params = {}) { const apiUrls = this.config.apiUrls; for (const baseUrl of apiUrls) { try { const queryParams = { server: "netease", type: "playlist", id: params.id, ...params, }; const queryString = new URLSearchParams(queryParams).toString(); const url = `${baseUrl}?${queryString}`; const response = await fetch(url, { mode: "cors", timeout: 5000 }); const data = await response.json(); if (!data) { continue; } return { code: 200, songs: data || [], }; } catch (error) { console.warn(`API ${baseUrl} 请求失败:`, error); continue; } } throw new Error("所有 API 都请求失败"); } getCacheKey(type, id) { return `${type}_${id}`; } setCache(key, data, expiry = 5 * 60 * 1000) { this.cache.set(key, { data, expiry: Date.now() + expiry, }); } getCache(key) { const cached = this.cache.get(key); if (cached && cached.expiry > Date.now()) { return cached.data; } this.cache.delete(key); return null; } async loadPlaylist(playlistId) { const cacheKey = this.getCacheKey("playlist_all", playlistId); let tracks = this.getCache(cacheKey); if (!tracks) { const response = await this.apiRequest("", { id: playlistId, }); tracks = response.songs || []; this.setCache(cacheKey, tracks); } if (!tracks || tracks.length === 0) { console.warn("歌单为空或无法加载,使用默认歌曲"); // 提供一个默认歌曲,避免播放器崩溃 // 使用特殊 ID "_empty" 表示这是一个占位符歌曲 this.playlist = [ { id: "_empty", name: "网络加载失败", artists: "Cloud Home", album: "Demo", picUrl: "", duration: 0, }, ]; return; } this.playlist = tracks.map((song) => { // Meting API 返回格式可能变化,需要从多个地方获取 ID let songId = song.id || song.mid; // 如果没有直接的 ID,尝试从 URL 中提取 if (!songId && song.url) { const urlMatch = song.url.match(/id=(\d+)/); songId = urlMatch ? urlMatch[1] : null; } // 或从 lrc URL 中提取 if (!songId && song.lrc) { const lrcMatch = song.lrc.match(/id=(\d+)/); songId = lrcMatch ? lrcMatch[1] : null; } if (!songId) { console.warn("歌曲缺少ID,无法播放:", song.title || song.name); } return { id: songId || "", name: song.name || song.title || "Unknown", artists: song.artist || song.author || "Unknown Artist", album: song.album || "Unknown Album", picUrl: song.pic || song.cover || "", duration: song.duration ? typeof song.duration === "string" ? parseInt(song.duration) * 1000 : song.duration * 1000 : 0, // 保存原始 API 返回的 URL 供后续使用 rawUrl: song.url || null, rawLyricUrl: song.lrc || null, }; }); this.setCache(cacheKey, tracks); this.updatePlaylistDisplay(); } async loadSingleSong(songId) { const cacheKey = this.getCacheKey("song", songId); let songData = this.getCache(cacheKey); if (!songData) { const apiUrls = [ `https://www.bilibili.uno/api?server=netease&type=song&id=${songId}`, `https://meting-api.wangcy.site/api?server=netease&type=song&id=${songId}`, ]; for (const url of apiUrls) { try { const response = await fetch(url); const songs = await response.json(); if (songs && songs.length > 0) { const song = songs[0]; songData = { id: song.id || song.mid || songId, name: song.name || song.title || "Unknown", artists: song.artist || song.author || "Unknown Artist", album: song.album || "Unknown Album", picUrl: song.pic || song.cover || "", duration: song.duration ? typeof song.duration === "string" ? parseInt(song.duration) * 1000 : song.duration * 1000 : 0, }; this.setCache(cacheKey, songData); break; } } catch (error) { console.warn("从此API获取歌曲失败:", error); continue; } } if (!songData) { throw new Error("歌曲信息获取失败"); } } this.playlist = [songData]; } async loadCurrentSong() { if (this.playlist.length === 0) return; if (this.showLyrics) { this.elements.lyricLine.textContent = "♪ 加载歌词中... ♪"; this.elements.lyricTranslation.style.display = "none"; this.elements.lyricLine.classList.remove("current", "scrolling"); this.elements.lyricTranslation.classList.remove("current", "scrolling"); this.lyrics = []; this.currentLyricIndex = -1; } const song = this.playlist[this.currentIndex]; this.currentSong = song; this.updateSongInfo(song); if (song.picUrl) { this.elements.albumCover.src = song.picUrl; } await this.loadSongUrl(song); if (this.showLyrics) { await this.loadLyrics(song); } } updateSongInfo(song) { if (!song) return; this.elements.songTitle.textContent = song.name || "未知歌曲"; if (song.artists) { const truncatedArtist = this.truncateArtistName(song.artists); this.elements.songArtist.textContent = truncatedArtist; if (truncatedArtist !== song.artists) { this.elements.songArtist.setAttribute("title", song.artists); } else { this.elements.songArtist.removeAttribute("title"); } } } truncateArtistName(artistText) { if (!artistText) return ""; const tempElement = document.createElement("span"); tempElement.style.visibility = "hidden"; tempElement.style.position = "absolute"; tempElement.style.fontSize = "12px"; tempElement.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; tempElement.textContent = artistText; document.body.appendChild(tempElement); const fullWidth = tempElement.offsetWidth; const availableWidth = 200; if (fullWidth <= availableWidth) { document.body.removeChild(tempElement); return artistText; } const artists = artistText.split(" / "); let result = ""; let currentWidth = 0; for (let i = 0; i < artists.length; i++) { const testText = result ? `${result} / ${artists[i]}` : artists[i]; tempElement.textContent = testText + "..."; const testWidth = tempElement.offsetWidth; if (testWidth > availableWidth) { if (result) { break; } else { const artist = artists[i]; for (let j = 1; j < artist.length; j++) { const partialArtist = artist.substring(0, j); tempElement.textContent = partialArtist + "..."; if (tempElement.offsetWidth > availableWidth) { result = artist.substring(0, Math.max(1, j - 1)); break; } result = partialArtist; } break; } } result = testText; } document.body.removeChild(tempElement); return result + (result !== artistText ? "..." : ""); } async loadSongUrl(song) { if (!song || !song.id || song.id === "_empty") { console.warn("歌曲对象无效,跳过加载音频URL"); return; } const songId = String(song.id); // 确保转换为字符串 const cacheKey = this.getCacheKey("song_url", songId); let urlData = this.getCache(cacheKey); if (!urlData) { // 优先尝试使用 playlist 中已有的 URL if (song.rawUrl) { try { const response = await fetch(song.rawUrl, { method: "HEAD" }); if (response.ok) { urlData = { url: song.rawUrl }; this.setCache(cacheKey, urlData, 30 * 60 * 1000); } } catch (error) { console.warn("验证歌单URL失败:", error); } } // 如果没有原始 URL,尝试从 API 获取 if (!urlData) { const baseUrls = this.config.apiUrls; const apiUrls = baseUrls.map( (baseUrl) => `${baseUrl}?server=netease&type=song&id=${songId}` ); for (const url of apiUrls) { try { const response = await fetch(url, { mode: "cors" }); const data = await response.json(); if (data && data.length > 0) { urlData = { url: data[0].url || data[0], }; this.setCache(cacheKey, urlData, 30 * 60 * 1000); break; } } catch (error) { console.warn("从此API获取失败:", error); continue; } } } } if (urlData && urlData.url) { const httpsUrl = this.ensureHttps(urlData.url); this.audio.src = httpsUrl; } else { console.warn("无法获取音频URL"); } } ensureHttps(url) { if (!url) return url; if (url.includes("music.126.net")) { return url.replace(/^http:\/\//, "https://"); } if (url.startsWith("http://")) { return url.replace("http://", "https://"); } return url; } async loadLyrics(song) { if (!song || !song.id || song.id === "_empty") { console.warn("歌曲对象无效,跳过加载歌词"); return; } const songId = String(song.id); // 确保转换为字符串 const cacheKey = this.getCacheKey("lyric", songId); let lyricData = this.getCache(cacheKey); if (!lyricData) { const baseUrls = this.config.apiUrls; const apiUrls = baseUrls.map((baseUrl) => `${baseUrl}?server=netease&type=lrc&id=${songId}`); for (const url of apiUrls) { try { const response = await fetch(url); const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { lyricData = await response.json(); } else { // API 直接返回 lrc 文件内容(纯文本) const lrcText = await response.text(); lyricData = { lrc: { lyric: lrcText } }; } if (lyricData) { this.setCache(cacheKey, lyricData, 60 * 60 * 1000); break; } } catch (error) { console.warn("从此API获取歌词失败:", error); continue; } } if (!lyricData) { console.warn("无法获取歌词"); this.lyrics = []; return; } } this.parseLyrics(lyricData); } parseLyrics(lyricData) { this.lyrics = []; this.currentLyricIndex = -1; if (!lyricData || (!lyricData.lrc?.lyric && !lyricData.tlyric?.lyric)) { this.elements.lyricLine.textContent = "暂无歌词"; this.elements.lyricTranslation.style.display = "none"; this.elements.lyricLine.classList.remove("current", "scrolling"); this.elements.lyricTranslation.classList.remove("current", "scrolling"); return; } // 处理 lrc 数据可能是字符串或对象的情况 const lrcContent = typeof lyricData.lrc === "string" ? lyricData.lrc : lyricData.lrc?.lyric || ""; const tlyricContent = typeof lyricData.tlyric === "string" ? lyricData.tlyric : lyricData.tlyric?.lyric || ""; const lrcLines = lrcContent.split("\n"); const tlyricLines = tlyricContent ? tlyricContent.split("\n") : []; const lrcMap = new Map(); lrcLines.forEach((line) => { const match = line.match(/\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/); if (match) { const minutes = parseInt(match[1]); const seconds = parseInt(match[2]); const milliseconds = parseInt(match[3].padEnd(3, "0")); const time = minutes * 60 + seconds + milliseconds / 1000; const text = match[4].trim(); if (text) { lrcMap.set(time, text); } } }); const tlyricMap = new Map(); tlyricLines.forEach((line) => { const match = line.match(/\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/); if (match) { const minutes = parseInt(match[1]); const seconds = parseInt(match[2]); const milliseconds = parseInt(match[3].padEnd(3, "0")); const time = minutes * 60 + seconds + milliseconds / 1000; const text = match[4].trim(); if (text) { tlyricMap.set(time, text); } } }); const allTimes = Array.from(new Set([...lrcMap.keys(), ...tlyricMap.keys()])).sort( (a, b) => a - b ); this.lyrics = allTimes.map((time) => ({ time, text: lrcMap.get(time) || "", translation: tlyricMap.get(time) || "", })); this.currentLyricIndex = -1; this.updateLyrics(); } async togglePlay() { if (this.isPlaying) { this.pause(); } else { await this.play(); } } async play() { GlobalAudioManager.setCurrent(this); try { await this.audio.play(); this.isPlaying = true; this.elements.playIcon.style.display = "none"; this.elements.pauseIcon.style.display = "inline"; this.elements.albumCover.classList.add("playing"); this.element.classList.add("player-playing"); } catch (error) { console.error("播放失败:", error); this.showError("播放失败"); } } pause() { this.audio.pause(); this.isPlaying = false; this.elements.playIcon.style.display = "inline"; this.elements.pauseIcon.style.display = "none"; this.elements.albumCover.classList.remove("playing"); this.element.classList.remove("player-playing"); } async previousSong() { if (this.playlist.length <= 1) return; this.currentIndex = this.currentIndex > 0 ? this.currentIndex - 1 : this.playlist.length - 1; await this.loadCurrentSong(); if (this.isPlaying) { await this.play(); } } async nextSong() { const wasPlaying = this.isPlaying; if (this.playlist.length <= 1) { if (this.playMode === "single") { this.audio.currentTime = 0; if (wasPlaying) await this.play(); return; } this.audio.currentTime = 0; if (wasPlaying) await this.play(); return; } let newIndex; if (this.playMode === "shuffle") { const availableIndices = this.playlist .map((_, i) => i) .filter((i) => i !== this.currentIndex); if (availableIndices.length === 0) { newIndex = this.currentIndex; } else { newIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]; } this.shuffleHistory.push(this.currentIndex); if (this.shuffleHistory.length > 2) { this.shuffleHistory.shift(); } } else if (this.playMode === "single") { newIndex = this.currentIndex; } else { newIndex = (this.currentIndex + 1) % this.playlist.length; } this.currentIndex = newIndex; await this.loadCurrentSong(); this.updatePlaylistDisplay(); if (wasPlaying) { setTimeout(async () => { try { await this.play(); } catch (error) { console.error("自动播放下一首失败:", error); } }, 100); } } updateProgress() { if (this.duration > 0) { const progress = (this.currentTime / this.duration) * 100; this.elements.progressBar.style.width = `${progress}%`; } } updateTimeDisplay() { const formatTime = (time) => { const minutes = Math.floor(time / 60); const seconds = Math.floor(time % 60); return `${minutes}:${seconds.toString().padStart(2, "0")}`; }; this.elements.currentTime.textContent = formatTime(this.currentTime); this.elements.totalTime.textContent = formatTime(this.duration); } updateVolumeDisplay() { this.elements.volumeBar.style.width = `${this.volume * 100}%`; } updateLyrics() { if (this.lyrics.length === 0) return; let newIndex = -1; for (let i = 0; i < this.lyrics.length; i++) { if (this.currentTime >= this.lyrics[i].time) { newIndex = i; } else { break; } } if (newIndex !== this.currentLyricIndex) { this.currentLyricIndex = newIndex; if (newIndex >= 0 && newIndex < this.lyrics.length) { const lyric = this.lyrics[newIndex]; const lyricText = lyric.text || "♪"; this.elements.lyricLine.classList.remove("current"); requestAnimationFrame(() => { this.elements.lyricLine.textContent = lyricText; this.checkLyricScrolling(this.elements.lyricLine, lyricText); this.elements.lyricLine.classList.add("current"); if (lyric.translation) { this.elements.lyricTranslation.textContent = lyric.translation; this.elements.lyricTranslation.style.display = "block"; this.elements.lyricTranslation.classList.remove("current"); requestAnimationFrame(() => { this.elements.lyricTranslation.classList.add("current"); }); } else { this.elements.lyricTranslation.style.display = "none"; this.elements.lyricTranslation.classList.remove("current", "scrolling"); } }); this.elements.lyricsContainer.classList.add("switching"); setTimeout(() => { this.elements.lyricsContainer.classList.remove("switching"); }, 500); if (lyric.translation) { this.elements.lyricTranslation.textContent = lyric.translation; this.elements.lyricTranslation.classList.add("current"); this.elements.lyricTranslation.style.display = "block"; this.checkLyricScrolling(this.elements.lyricTranslation, lyric.translation); } else { this.elements.lyricTranslation.style.display = "none"; this.elements.lyricTranslation.classList.remove("current", "scrolling"); } } else { this.elements.lyricLine.textContent = "♪ 纯音乐,请欣赏 ♪"; this.elements.lyricLine.classList.remove("current", "scrolling"); this.elements.lyricTranslation.style.display = "none"; this.elements.lyricTranslation.classList.remove("current", "scrolling"); } } } checkLyricScrolling(element, text) { if (!element || !text) return; const tempElement = document.createElement("span"); tempElement.style.visibility = "hidden"; tempElement.style.position = "absolute"; tempElement.style.fontSize = window.getComputedStyle(element).fontSize; tempElement.style.fontFamily = window.getComputedStyle(element).fontFamily; tempElement.style.fontWeight = window.getComputedStyle(element).fontWeight; tempElement.textContent = text; document.body.appendChild(tempElement); const textWidth = tempElement.offsetWidth; document.body.removeChild(tempElement); const containerWidth = element.parentElement.offsetWidth - 16; if (textWidth > containerWidth) { element.classList.add("scrolling"); } else { element.classList.remove("scrolling"); } } updatePlaylistDisplay() { if (!this.elements.playlistContent || !this.playlist || this.playlist.length === 0) return; const html = this.playlist .map( (song, index) => `
${(index + 1).toString().padStart(2, "0")}
专辑封面
${song.name}
${song.artists}
` ) .join(""); this.elements.playlistContent.innerHTML = html; this.elements.playlistContent.querySelectorAll(".playlist-item").forEach((item) => { item.addEventListener("click", async () => { const index = parseInt(item.dataset.index); if (index !== this.currentIndex) { this.currentIndex = index; await this.loadCurrentSong(); if (this.isPlaying) { await this.play(); } this.updatePlaylistDisplay(); this.togglePlaylist(); } }); }); const activeItem = this.elements.playlistContent.querySelector(".playlist-item.active"); if (activeItem) { activeItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); } } seekTo(e) { if (!this.elements.progressContainer || !this.audio) return; const rect = this.elements.progressContainer.getBoundingClientRect(); const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const newTime = percent * this.duration; if (isFinite(newTime) && newTime >= 0) { this.audio.currentTime = newTime; } } setVolume(e) { if (!this.elements.volumeSlider) return; const rect = this.elements.volumeSlider.getBoundingClientRect(); const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); this.volume = percent; this.audio.volume = this.volume; this.updateVolumeDisplay(); } toggleLyrics() { this.showLyrics = !this.showLyrics; this.elements.lyricsContainer.classList.toggle("hidden", !this.showLyrics); this.elements.lyricsBtn.classList.toggle("active", this.showLyrics); } togglePlaylist(show = null) { if (!this.elements.playlistContainer) return; const isShowing = this.elements.playlistContainer.classList.contains("show"); const shouldShow = show !== null ? show : !isShowing; if (shouldShow) { this.determinePlaylistDirection(); this.updatePlaylistDisplay(); this.elements.playlistContainer.classList.add("show"); if (this.elements.listBtn) { this.elements.listBtn.classList.add("active"); } } else { this.elements.playlistContainer.classList.remove("show", "show-above", "show-below"); if (this.elements.listBtn) { this.elements.listBtn.classList.remove("active"); } } } togglePlayMode() { const modes = ["list", "single", "shuffle"]; const currentIndex = modes.indexOf(this.playMode); this.playMode = modes[(currentIndex + 1) % 3]; const iconSvgs = { list: ICONS.loopList, single: ICONS.loopSingle, shuffle: ICONS.shuffle }; const titles = { list: "列表循环", single: "单曲循环", shuffle: "随机播放" }; if (this.elements.loopModeBtn) { this.elements.loopModeBtn.innerHTML = iconSvgs[this.playMode]; this.elements.loopModeBtn.title = titles[this.playMode]; } } toggleMinimize() { const isCurrentlyMinimized = this.element.classList.contains("minimized"); this.isMinimized = isCurrentlyMinimized; if (!isCurrentlyMinimized) { this.element.classList.add("minimized"); this.isMinimized = true; if (this.elements.minimizeBtn) { this.elements.minimizeBtn.classList.add("active"); this.elements.minimizeBtn.title = "展开"; this.elements.minimizeBtn.innerHTML = ICONS.maximize; } this.clearIdleTimer(); this.isIdle = false; this.element.classList.remove( "idle", "fading-in", "fading-out", "docked-left", "docked-right", "popping-left", "popping-right" ); this.startIdleTimer(); } else { this.element.classList.remove("minimized"); this.isMinimized = false; if (this.elements.minimizeBtn) { this.elements.minimizeBtn.classList.remove("active"); this.elements.minimizeBtn.title = "缩小"; this.elements.minimizeBtn.innerHTML = ICONS.minimize; } this.clearIdleTimer(); if (this.isIdle) { this.restoreOpacity(); } else { this.element.classList.remove( "idle", "fading-in", "fading-out", "docked-left", "docked-right", "popping-left", "popping-right" ); } this.isIdle = false; } } determinePlaylistDirection() { const playerRect = this.element.getBoundingClientRect(); const viewportHeight = window.innerHeight; const spaceBelow = viewportHeight - playerRect.bottom; const spaceAbove = playerRect.top; const playlistHeight = 220; this.elements.playlistContainer.classList.remove("expand-up"); if (spaceBelow >= playlistHeight || spaceBelow >= spaceAbove) { } else { this.elements.playlistContainer.classList.add("expand-up"); } } setupDragAndDrop() { return; } showError(message) { this.elements.songTitle.textContent = message; this.elements.songArtist.textContent = ""; this.elements.lyricLine.textContent = ""; } initTheme() { this.setTheme(this.config.theme); if (this.config.theme === "auto") { this.setupThemeListener(); } } setTheme(theme) { if (theme === "auto") { const detectedTheme = this.detectTheme(); this.element.setAttribute("data-theme", "auto"); if (detectedTheme === "dark") { this.element.classList.add("theme-dark-detected"); } else { this.element.classList.remove("theme-dark-detected"); } } else { this.element.setAttribute("data-theme", theme); this.element.classList.remove("theme-dark-detected"); } } detectTheme() { const hostTheme = this.detectHostTheme(); if (hostTheme) { return hostTheme; } const cssTheme = this.detectCSSTheme(); if (cssTheme) { return cssTheme; } return this.detectSystemTheme(); } detectHostTheme() { const html = document.documentElement; const body = document.body; const darkClasses = ["dark", "theme-dark", "dark-theme", "dark-mode"]; const lightClasses = ["light", "theme-light", "light-theme", "light-mode"]; for (const className of darkClasses) { if (html.classList.contains(className)) return "dark"; } for (const className of lightClasses) { if (html.classList.contains(className)) return "light"; } if (body) { for (const className of darkClasses) { if (body.classList.contains(className)) return "dark"; } for (const className of lightClasses) { if (body.classList.contains(className)) return "light"; } } const htmlTheme = html.getAttribute("data-theme"); if (htmlTheme === "dark" || htmlTheme === "light") { return htmlTheme; } const bodyTheme = body?.getAttribute("data-theme"); if (bodyTheme === "dark" || bodyTheme === "light") { return bodyTheme; } return null; } detectCSSTheme() { try { const rootStyles = getComputedStyle(document.documentElement); const bgColor = rootStyles.getPropertyValue("--bg-color") || rootStyles.getPropertyValue("--background-color") || rootStyles.getPropertyValue("--color-bg"); const textColor = rootStyles.getPropertyValue("--text-color") || rootStyles.getPropertyValue("--color-text") || rootStyles.getPropertyValue("--text-primary"); if (bgColor || textColor) { const isDarkBg = this.isColorDark(bgColor); const isLightText = this.isColorLight(textColor); if (isDarkBg || isLightText) { return "dark"; } if (!isDarkBg || !isLightText) { return "light"; } } } catch (error) { console.warn("CSS主题检测失败:", error); } return null; } detectSystemTheme() { if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { return "dark"; } return "light"; } isColorDark(color) { if (!color) return false; color = color.replace(/\s/g, "").toLowerCase(); if (color.includes("dark") || color.includes("black") || color === "transparent") { return true; } const rgb = color.match(/rgb\((\d+),(\d+),(\d+)\)/); if (rgb) { const [, r, g, b] = rgb.map(Number); const brightness = (r * 299 + g * 587 + b * 114) / 1000; return brightness < 128; } const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/); if (hex) { const hexValue = hex[1]; const r = parseInt( hexValue.length === 3 ? hexValue[0] + hexValue[0] : hexValue.substr(0, 2), 16 ); const g = parseInt( hexValue.length === 3 ? hexValue[1] + hexValue[1] : hexValue.substr(2, 2), 16 ); const b = parseInt( hexValue.length === 3 ? hexValue[2] + hexValue[2] : hexValue.substr(4, 2), 16 ); const brightness = (r * 299 + g * 587 + b * 114) / 1000; return brightness < 128; } return false; } isColorLight(color) { return !this.isColorDark(color); } setupThemeListener() { if (window.matchMedia) { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleThemeChange = () => { if (this.config.theme === "auto") { this.setTheme("auto"); } }; if (mediaQuery.addEventListener) { mediaQuery.addEventListener("change", handleThemeChange); } else { mediaQuery.addListener(handleThemeChange); } } if (window.MutationObserver) { const observer = new MutationObserver((mutations) => { if (this.config.theme === "auto") { let shouldUpdate = false; mutations.forEach((mutation) => { if ( mutation.type === "attributes" && (mutation.attributeName === "class" || mutation.attributeName === "data-theme") ) { shouldUpdate = true; } }); if (shouldUpdate) { this.setTheme("auto"); } } }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"], }); if (document.body) { observer.observe(document.body, { attributes: true, attributeFilter: ["class", "data-theme"], }); } } } static init() { document.querySelectorAll(".netease-mini-player").forEach((element) => { if (!element._neteasePlayer) { element._neteasePlayer = new NeteaseMiniPlayer(element); } }); } static initPlayer(element) { if (!element._neteasePlayer) { element._neteasePlayer = new NeteaseMiniPlayer(element); } return element._neteasePlayer; } } if (typeof window !== "undefined") { window.NeteaseMiniPlayer = NeteaseMiniPlayer; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", NeteaseMiniPlayer.init); } else { NeteaseMiniPlayer.init(); } } class NMPv2ShortcodeParser { constructor() { this.paramMappings = { position: "data-position", theme: "data-theme", lyric: "data-lyric", embed: "data-embed", minimized: "data-default-minimized", autoplay: "data-autoplay", "idle-opacity": "data-idle-opacity", "auto-pause": "data-auto-pause", }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => this.init()); } else { this.init(); } } init() { this.processContainer(document.body); } /** * 处理容器内的所有短语法 */ processContainer(container) { this.processTextNodes(container); this.processExistingElements(container); this.initializePlayers(container); } /** * 处理文本节点中的短语法 */ processTextNodes(container) { const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false); const textNodes = []; let node; while ((node = walker.nextNode())) { if (node.textContent.includes("{nmpv2:")) { textNodes.push(node); } } textNodes.forEach((node) => { const content = node.textContent; const shortcodes = this.extractShortcodes(content); if (shortcodes.length > 0) { const fragment = document.createDocumentFragment(); let lastIndex = 0; shortcodes.forEach((shortcode) => { if (shortcode.startIndex > lastIndex) { fragment.appendChild( document.createTextNode(content.substring(lastIndex, shortcode.startIndex)) ); } const playerElement = this.createPlayerElement(shortcode); fragment.appendChild(playerElement); lastIndex = shortcode.endIndex; }); if (lastIndex < content.length) { fragment.appendChild(document.createTextNode(content.substring(lastIndex))); } node.parentNode.replaceChild(fragment, node); } }); } processExistingElements(container) { container .querySelectorAll(".netease-mini-player:not([data-shortcode-processed])") .forEach((element) => { element.setAttribute("data-shortcode-processed", "true"); }); } initializePlayers(container) { container .querySelectorAll(".netease-mini-player:not([data-initialized])") .forEach((element) => { element.setAttribute("data-initialized", "true"); NeteaseMiniPlayer.initPlayer(element); }); } extractShortcodes(text) { const regex = /\{nmpv2:([^}]*)\}/g; let match; const results = []; let lastIndex = 0; while ((match = regex.exec(text)) !== null) { const content = match[1].trim(); const startIndex = match.index; const endIndex = match.index + match[0].length; let shortcode = { type: "song", id: null, params: {}, startIndex, endIndex, }; this.parseShortcodeContent(content, shortcode); results.push(shortcode); } return results; } parseShortcodeContent(content, shortcode) { if (content.startsWith("playlist=")) { shortcode.type = "playlist"; const parts = content.split(/,\s*/); const firstPart = parts.shift(); shortcode.id = firstPart.replace("playlist=", "").trim(); parts.forEach((part) => this.parseParam(part, shortcode.params)); } else if (content.includes("=")) { const parts = content.split(/,\s*/); const firstPart = parts.shift(); if (firstPart.includes("=")) { this.parseParam(firstPart, shortcode.params); parts.forEach((part) => this.parseParam(part, shortcode.params)); } else { shortcode.id = firstPart.trim(); parts.forEach((part) => this.parseParam(part, shortcode.params)); } } else { shortcode.id = content.trim(); } if (shortcode.params.position === undefined || shortcode.params.position === "static") { shortcode.params.embed = shortcode.params.embed ?? "true"; } else if (shortcode.params.embed === undefined) { shortcode.params.embed = "false"; } } parseParam(paramStr, params) { const [key, value] = paramStr.split("="); if (!key || !value) return; const cleanKey = key.trim().toLowerCase(); const cleanValue = value.trim().toLowerCase(); if (cleanKey === "song-id") { params.songId = cleanValue; } else if (cleanKey === "playlist-id") { params.playlistId = cleanValue; params.type = "playlist"; } else if (cleanKey === "minimized") { params.defaultMinimized = cleanValue === "true" ? "true" : "false"; } else { const mapping = this.paramMappings[cleanKey] || `data-${cleanKey}`; params[cleanKey] = cleanValue; } } createPlayerElement(shortcode) { const div = document.createElement("div"); div.className = "netease-mini-player"; div.setAttribute("data-shortcode-processed", "true"); if (shortcode.type === "playlist" && shortcode.id) { div.setAttribute("data-playlist-id", shortcode.id); } else if (shortcode.id) { div.setAttribute("data-song-id", shortcode.id); } Object.entries(shortcode.params).forEach(([key, value]) => { if (key === "songId") { div.setAttribute("data-song-id", value); } else if (key === "playlistId") { div.setAttribute("data-playlist-id", value); } else if (key === "type") { } else { const dataKey = this.paramMappings[key] || `data-${key}`; div.setAttribute(dataKey, value); } }); return div; } static processDynamicContent(content) { const tempDiv = document.createElement("div"); tempDiv.innerHTML = content; window.nmpv2ShortcodeParser.processContainer(tempDiv); return tempDiv.innerHTML; } } if (typeof window !== "undefined") { window.nmpv2ShortcodeParser = new NMPv2ShortcodeParser(); window.processNMPv2Shortcodes = function (container) { if (container instanceof Element) { window.nmpv2ShortcodeParser.processContainer(container); } else { console.warn("processNMPv2Shortcodes requires a DOM element"); } }; } if (typeof module !== "undefined" && module.exports) { module.exports = { renderShortcodes: function (html) { return html.replace(/\{nmpv2:([^}]*)\}/g, (match, content) => { let shortcode = { type: "song", id: null, params: {}, }; if (content.startsWith("playlist=")) { shortcode.type = "playlist"; const parts = content.split(/,\s*/); shortcode.id = parts[0].replace("playlist=", "").trim(); parts.slice(1).forEach((part) => { const [key, value] = part.split("="); if (key && value) shortcode.params[key.trim()] = value.trim(); }); } else { const parts = content.split(/,\s*/); if (parts[0].includes("=")) { parts.forEach((part) => { const [key, value] = part.split("="); if (key && value) shortcode.params[key.trim()] = value.trim(); }); } else { shortcode.id = parts[0].trim(); parts.slice(1).forEach((part) => { const [key, value] = part.split("="); if (key && value) shortcode.params[key.trim()] = value.trim(); }); } } if (!shortcode.params.position || shortcode.params.position === "static") { shortcode.params.embed = shortcode.params.embed ?? "true"; } else if (shortcode.params.embed === undefined) { shortcode.params.embed = "false"; } let html = '
{ if (key === "songId") { html += ` data-song-id="${value}"`; } else if (key === "playlistId") { html += ` data-playlist-id="${value}"`; } else { const dataKey = { position: "data-position", theme: "data-theme", lyric: "data-lyric", embed: "data-embed", minimized: "data-default-minimized", autoplay: "data-autoplay", "idle-opacity": "data-idle-opacity", "auto-pause": "data-auto-pause", }[key] || `data-${key}`; html += ` ${dataKey}="${value}"`; } }); html += ">
"; return html; }); }, }; } console.log( [ "版本号 v2.1.0", "NeteaseMiniPlayer V2 [NMPv2]", "BHCN STUDIO & 北海的佰川(ImBHCN[numakkiyu])", "GitHub地址:https://github.com/numakkiyu/NeteaseMiniPlayer", "基于 Apache 2.0 开源协议发布", ].join("\n") );