From 2cdd81e74b3f6e2c0adad02f20589ce8407ddbbf Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sun, 7 Dec 2025 16:36:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=9A=E8=BF=87=20NeteaseMiniPlayer?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E6=92=AD=E6=94=BE=E6=9D=A5=E8=87=AA?= =?UTF-8?q?=E7=BD=91=E6=98=93=E4=BA=91=E9=9F=B3=E4=B9=90=E7=9A=84=E9=9F=B3?= =?UTF-8?q?=E4=B9=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 + index.html | 4 + package.json | 1 + public/js/netease-mini-player-v2.js | 1585 +++++++++++++++++++++++++++ src/App.vue | 5 +- src/components/MusicPlayer.vue | 30 + src/config/siteConfig.ts | 20 + src/main.ts | 1 + src/styles.css | 101 +- 9 files changed, 1775 insertions(+), 2 deletions(-) create mode 100644 public/js/netease-mini-player-v2.js create mode 100644 src/components/MusicPlayer.vue diff --git a/README.md b/README.md index 86a9e60..1dafe7a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ - 构建:Vite - 部署:Vercel(静态构建 + Serverless Functions) +## 致谢 + +排名不分先后 + +- [`Netease Mini Player`](https://github.com/numakkiyu/NeteaseMiniPlayer): 迷你网易云播放器组件,为本项目的音乐播放功能提供支持。 + +感谢以上开源项目原作者与维护者的贡献。 + ## 配置指南 ### 站点配置文件 (`src/config/siteConfig.ts`) @@ -61,6 +69,28 @@ const siteConfig: SiteConfig = { startDate:"xxxx-xx-xx", // 网站创建日期 }, + music: { + // 是否启用音乐播放器 + enable: true, + // floating - 浮动模式播放器(推荐)- 用于播放网易云歌单 + // embed - 嵌入模式播放器 - 用于播放网易云单曲 + mode: "floating", // "floating" 或 "embed" + // 歌单ID:从网易云音乐链接获取,如 https://music.163.com/#/playlist?id=14273792576 + playlistId: undefined, // 例如: "14273792576" + // 歌曲ID:仅在嵌入模式下使用 + songId: undefined, // 例如: "554242291" + // 播放器位置(浮动模式): "bottom-left" | "bottom-right" | "top-left" | "top-right" + position: "bottom-left", + // 是否显示歌词 + lyric: true, + // 主题: "light" | "dark" | "auto" + theme: "dark", + // 是否自动播放 + autoplay: false, + // 是否默认以黑胶唱片状态启动(仅浮动模式) + defaultMinimized: true, + }, + umami: { enable: true, // 是否启用 Umami 分析 url: "https://cloud.umami.is/script.js", // Umami 分析脚本 URL,一般无需修改 diff --git a/index.html b/index.html index 153041f..2ba24c7 100644 --- a/index.html +++ b/index.html @@ -6,11 +6,15 @@ Cloud Home + +
+ + \ No newline at end of file diff --git a/package.json b/package.json index f54fba9..86a71df 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dependencies": { "@jaseeey/vue-umami-plugin": "^1.4.0", "@vercel/node": "^5.5.15", + "cros": "^1.1.0", "express": "^5.2.1", "he": "^1.2.0", "nodemailer": "^7.0.11", diff --git a/public/js/netease-mini-player-v2.js b/public/js/netease-mini-player-v2.js new file mode 100644 index 0000000..c4efbf3 --- /dev/null +++ b/public/js/netease-mini-player-v2.js @@ -0,0 +1,1585 @@ +/** + * [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; + + 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 + }; + } + 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') { + document.addEventListener('visibilitychange', () => { + if (this.config.autoPauseDisabled === true) { + return; + } + 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 baseUrl = 'https://api.hypcvgm.top/NeteaseMiniPlayer/nmp.php'; + const queryString = new URLSearchParams(params).toString(); + const url = `${baseUrl}${endpoint}${queryString ? '?' + queryString : ''}`; + try { + const response = await fetch(url); + const data = await response.json(); + if (data.code !== 200) { + throw new Error(`API错误: ${data.code}`); + } + return data; + } catch (error) { + console.error('API请求失败:', error); + throw error; + } + } + 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('/playlist/track/all', { + id: playlistId, + limit: 1000, + offset: 0 + }); + tracks = response.songs; + this.setCache(cacheKey, tracks); + } + this.playlist = tracks.map(song => ({ + id: song.id, + name: song.name, + artists: song.ar.map(ar => ar.name).join(', '), + album: song.al.name, + picUrl: song.al.picUrl, + duration: song.dt + })); + this.updatePlaylistDisplay(); + } + async loadSingleSong(songId) { + const cacheKey = this.getCacheKey('song', songId); + let songData = this.getCache(cacheKey); + if (!songData) { + try { + const response = await this.apiRequest('/song/detail', { ids: songId }); + if (response.songs && response.songs.length > 0) { + const song = response.songs[0]; + songData = { + id: song.id, + name: song.name, + artists: song.ar.map(ar => ar.name).join(', '), + album: song.al.name, + picUrl: song.al.picUrl, + duration: song.dt + }; + this.setCache(cacheKey, songData); + } else { + throw new Error('歌曲信息获取失败'); + } + } catch (error) { + console.error('获取歌曲详情失败:', error); + songData = { + id: songId, + name: '歌曲加载失败', + artists: '未知艺术家', + album: '未知专辑', + picUrl: '', + duration: 0 + }; + } + } + 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.id); + if (this.showLyrics) { + await this.loadLyrics(song.id); + } + } + 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(songId) { + const cacheKey = this.getCacheKey('song_url', songId); + let urlData = this.getCache(cacheKey); + if (!urlData) { + try { + const response = await this.apiRequest('/song/url/v1', { + id: songId, + level: 'exhigh' + }); + if (response.data && response.data.length > 0) { + urlData = response.data[0]; + this.setCache(cacheKey, urlData, 30 * 60 * 1000); + } + } catch (error) { + console.error('获取音频URL失败:', error); + try { + const fallbackResponse = await this.apiRequest('/song/url/v1', { + id: songId, + level: 'standard' + }); + if (fallbackResponse.data && fallbackResponse.data.length > 0) { + urlData = fallbackResponse.data[0]; + } + } catch (fallbackError) { + console.error('降级获取音频URL也失败:', fallbackError); + } + } + } + if (urlData && urlData.url) { + const httpsUrl = this.ensureHttps(urlData.url); + console.log('设置音频源:', httpsUrl); + this.audio.src = httpsUrl; + } else { + throw new Error('无法获取播放地址'); + } + } + 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(songId) { + const cacheKey = this.getCacheKey('lyric', songId); + let lyricData = this.getCache(cacheKey); + if (!lyricData) { + try { + const response = await this.apiRequest('/lyric', { id: songId }); + lyricData = response; + this.setCache(cacheKey, lyricData, 60 * 60 * 1000); + } catch (error) { + console.error('获取歌词失败:', error); + 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; + } + const lrcLines = lyricData.lrc.lyric.split('\n'); + const tlyricLines = lyricData.tlyric && lyricData.tlyric.lyric ? + lyricData.tlyric.lyric.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")); diff --git a/src/App.vue b/src/App.vue index a11b4b4..7366aae 100644 --- a/src/App.vue +++ b/src/App.vue @@ -21,6 +21,8 @@ + + @@ -28,6 +30,7 @@ import { onMounted, computed, ref } from "vue"; import PageSwitcher from "./components/PageSwitcher.vue"; import FooterSection from "./components/FooterSection.vue"; +import MusicPlayer from "./components/MusicPlayer.vue"; import siteConfig from "./config/siteConfig"; const contact = siteConfig.footer; @@ -53,7 +56,7 @@ const getBackgroundImage = () => { if (!image) return undefined; - return image.startsWith("http") ? image : `/public/${image}`; + return image.startsWith("http") ? image : `/${image}`; }; const backgroundStyle = computed(() => { diff --git a/src/components/MusicPlayer.vue b/src/components/MusicPlayer.vue new file mode 100644 index 0000000..e90d1e5 --- /dev/null +++ b/src/components/MusicPlayer.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/config/siteConfig.ts b/src/config/siteConfig.ts index 8d61c1c..45c20b4 100644 --- a/src/config/siteConfig.ts +++ b/src/config/siteConfig.ts @@ -47,6 +47,26 @@ const siteConfig = { }, }, + music: { + enable: true, + // 浮动模式播放器(推荐)- 用于播放网易云歌单 + mode: "floating", // "floating" 或 "embed" + // 歌单ID:从网易云音乐链接获取,如 https://music.163.com/#/playlist?id=14273792576 + playlistId: "14366453940", // 例如: "14273792576" + // 歌曲ID:仅在嵌入模式下使用 + songId: undefined, // 例如: "554242291" + // 播放器位置(浮动模式): "bottom-left" | "bottom-right" | "top-left" | "top-right" + position: "bottom-left", + // 是否显示歌词 + lyric: true, + // 主题: "light" | "dark" | "auto" + theme: "dark", + // 是否自动播放 + autoplay: false, + // 是否默认以黑胶唱片状态启动(仅浮动模式) + defaultMinimized: true, + }, + umami: { enable: true, url: "https://cloud.umami.is/script.js", diff --git a/src/main.ts b/src/main.ts index 0811677..eb4b0f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,4 +12,5 @@ if (process.env.NODE_ENV !== "development") { app.use(VueUmamiPlugin({ websiteID: siteConfig.umami.websiteId, scriptSrc: siteConfig.umami.url, router })); } } + app.use(router).mount("#app"); diff --git a/src/styles.css b/src/styles.css index 68d91ef..8013756 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,3 +1,7 @@ +html { + height: 100%; +} + :root { color-scheme: light dark; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -7,7 +11,7 @@ body { margin: 0; - min-height: 100vh; + min-height: 100%; background: radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629); } @@ -90,3 +94,98 @@ p { text-align: center; margin-top: 8px; } + +/* Ensure the Netease mini player doesn't occupy page layout space + and stays fixed above content. Use !important to override + styles injected by third-party scripts. */ +.netease-mini-player { + position: fixed !important; + bottom: 20px !important; + left: 20px !important; + right: auto !important; + /* do not force width here — let the player's own minimized/expanded + styles control sizing. Only constrain max width as a safety net. */ + max-width: calc(100% - 40px) !important; + z-index: 9999 !important; + margin: 0 !important; + transform: none !important; +} + +/* If the player injects a full-width bar, make sure it won't + push page content by forcing it out of the normal flow. */ +.netease-mini-player > * { + box-sizing: border-box; +} + +/* Defensive: hide any accidental full-width injected container from the player + that might occupy vertical space. This targets common helper classes used + by the player script. */ +.netease-mini-player-embed, +.nmpv2-player, +.nmpv2-root { + position: fixed !important; + bottom: 20px !important; + left: 20px !important; + right: auto !important; + z-index: 9999 !important; +} + +/* Fix: prevent playlist dropdown from increasing document height + by forcing the playlist container to be fixed and clipped to viewport. */ +.netease-mini-player[data-position="bottom-left"] .playlist-container, +.netease-mini-player[data-position="bottom-right"] .playlist-container { + position: fixed !important; + bottom: calc(20px + 80px) !important; + left: 20px !important; + right: auto !important; + width: 290px !important; + max-height: 50vh !important; + overflow: auto !important; + z-index: 10001 !important; +} + +.netease-mini-player[data-position="top-left"] .playlist-container, +.netease-mini-player[data-position="top-right"] .playlist-container { + position: fixed !important; + top: calc(20px + 80px) !important; + left: 20px !important; + right: auto !important; + width: 290px !important; + max-height: 50vh !important; + overflow: auto !important; + z-index: 10001 !important; +} + +/* If player is docked to the right, align playlist to right edge */ +.netease-mini-player[data-position="bottom-right"] .playlist-container, +.netease-mini-player[data-position="top-right"] .playlist-container { + left: auto !important; + right: 20px !important; +} + +/* Ensure minimized player displays as a circle and its album cover fits */ +.netease-mini-player.minimized { + width: 80px !important; + height: 80px !important; + border-radius: 50% !important; + padding: 0 !important; + overflow: hidden !important; + box-shadow: none !important; +} + +.netease-mini-player.minimized .album-cover-container { + width: 100% !important; + height: 100% !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + border-radius: 50% !important; + overflow: hidden !important; +} + +.netease-mini-player.minimized .album-cover { + width: 100% !important; + height: 100% !important; + object-fit: cover !important; + border-radius: 50% !important; +}