From f372e7bfb4a58c83adf80f93d05a74212b97cef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E6=96=B0=E7=82=80?= <2489083744@qq.com> Date: Wed, 10 Jun 2026 23:55:54 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=8B=96?= =?UTF-8?q?=E6=8B=BD=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E8=A7=A6?= =?UTF-8?q?=E6=91=B8=E3=80=81=E8=BE=B9=E7=BC=98=E5=90=B8=E9=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netease-mini-player-v2-dev.js | 201 ++++++++++++++++++++++++++++++++-- 1 file changed, 193 insertions(+), 8 deletions(-) diff --git a/netease-mini-player-v2-dev.js b/netease-mini-player-v2-dev.js index 2383d44..4295874 100644 --- a/netease-mini-player-v2-dev.js +++ b/netease-mini-player-v2-dev.js @@ -171,6 +171,11 @@ class NeteaseMiniPlayer { this.idleTimeout = null; this.idleDelay = 5000; this.isIdle = false; + this.isDragging = false; + this.dragStartX = 0; + this.dragStartY = 0; + this.initialLeft = 0; + this.initialTop = 0; } /** * 解析容器上的`data-*`属性为内部配置 @@ -1736,20 +1741,200 @@ class NeteaseMiniPlayer { } } /** - * 预留的拖拽定位功能(当前禁用) + * 可拖拽交互元素的CSS选择器,用于排除控件区域 + * @type {string} + * @private + */ + static DRAG_EXCLUDE_SELECTOR = [ + '.control-btn', + '.feature-btn', + '.progress-bar-container', + '.volume-slider', + '.volume-slider-container', + '.playlist-container', + '.playlist-content', + 'a', + 'button', + 'input', + 'select' + ].join(', '); + + /** + * 实现拖拽定位功能,支持鼠标/触摸、边缘吸附与空闲状态联动 * @returns {void} * @description - * 1. 当前为预留接口,仅返回不做任何操作 - * 2. 计划实现播放器的拖拽定位功能 - * 3. 将支持在页面中自由拖动播放器到任意位置 - * 4. 需要处理拖拽开始、移动、结束等事件 - * @todo 实现拖拽定位功能,支持播放器在页面中的自由拖动 + * 1. 同时绑定 mouse/touch 事件,统一通过 _onDragStart/_onDragMove/_onDragEnd 处理 + * 2. 使用 closest() 精确排除按钮、进度条、音量条等交互控件 + * 3. 拖拽开始时清除空闲计时器、移除 idle/docked 类、添加 dragging 视觉反馈 + * 4. 拖拽过程中禁用 CSS transition 保证跟手流畅 + * 5. 拖拽结束后执行边缘吸附,最小化状态下重新启动空闲计时器 * @example - * player.setupDragAndDrop(); // 当前无实际操作 + * player.setupDragAndDrop(); * @private */ setupDragAndDrop() { - return; + this.snapThreshold = 20; + this.element.style.position = 'fixed'; + + const onDragStart = (e) => this._onDragStart(e); + this.element.addEventListener('mousedown', onDragStart); + this.element.addEventListener('touchstart', onDragStart, { passive: false }); + + this._boundDragMove = (e) => this._onDragMove(e); + this._boundDragEnd = (e) => this._onDragEnd(e); + } + + /** + * 统一拖拽开始处理(鼠标和触摸) + * @param {MouseEvent|TouchEvent} e + * @private + */ + _onDragStart(e) { + const target = e.target; + if (target.closest(NeteaseMiniPlayer.DRAG_EXCLUDE_SELECTOR)) { + return; + } + + const point = e.touches ? e.touches[0] : e; + if (!point) return; + + if (e.type === 'touchstart') { + e.preventDefault(); + } else { + e.preventDefault(); + } + + this.clearIdleTimer(); + this.isIdle = false; + this.element.classList.remove( + 'idle', 'fading-in', 'fading-out', + 'docked-left', 'docked-right', + 'popping-left', 'popping-right' + ); + + this.isDragging = true; + this.element.classList.add('dragging'); + this.element.style.transition = 'none'; + this.element.style.zIndex = '9999'; + this.element.style.cursor = 'grabbing'; + + this.dragStartX = point.clientX; + this.dragStartY = point.clientY; + const rect = this.element.getBoundingClientRect(); + this.initialLeft = rect.left; + this.initialTop = rect.top; + + document.addEventListener('mousemove', this._boundDragMove); + document.addEventListener('mouseup', this._boundDragEnd); + document.addEventListener('touchmove', this._boundDragMove, { passive: false }); + document.addEventListener('touchend', this._boundDragEnd); + document.addEventListener('touchcancel', this._boundDragEnd); + } + + /** + * 统一拖拽移动处理(鼠标和触摸) + * @param {MouseEvent|TouchEvent} e + * @private + */ + _onDragMove = (e) => { + if (!this.isDragging) return; + + // 触摸时阻止页面滚动 + if (e.type === 'touchmove') { + e.preventDefault(); + } + + const point = e.touches ? e.touches[0] : e; + if (!point) return; + + const deltaX = point.clientX - this.dragStartX; + const deltaY = point.clientY - this.dragStartY; + + let newLeft = this.initialLeft + deltaX; + let newTop = this.initialTop + deltaY; + + const vw = window.innerWidth; + const vh = window.innerHeight; + const pw = this.element.offsetWidth; + const ph = this.element.offsetHeight; + newLeft = Math.max(0, Math.min(newLeft, vw - pw)); + newTop = Math.max(0, Math.min(newTop, vh - ph)); + + this.element.style.left = `${newLeft}px`; + this.element.style.top = `${newTop}px`; + } + + /** + * 统一拖拽结束处理(鼠标和触摸) + * @param {MouseEvent|TouchEvent} e + * @private + */ + _onDragEnd = (e) => { + if (!this.isDragging) return; + + this.isDragging = false; + + document.removeEventListener('mousemove', this._boundDragMove); + document.removeEventListener('mouseup', this._boundDragEnd); + document.removeEventListener('touchmove', this._boundDragMove); + document.removeEventListener('touchend', this._boundDragEnd); + document.removeEventListener('touchcancel', this._boundDragEnd); + + this.element.classList.remove('dragging'); + this.element.style.zIndex = ''; + this.element.style.cursor = ''; + + this.snapToEdge(); + + if (this.isMinimized) { + this.startIdleTimer(); + } + } + + /** + * 边缘吸附 检测播放器与视口四边的距离,小于阈值自动贴边 + * @returns {void} + * @private + */ + snapToEdge() { + const rect = this.element.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const threshold = this.snapThreshold || 20; + + let snappedLeft = rect.left; + let snappedTop = rect.top; + let didSnap = false; + + if (rect.left < threshold) { + snappedLeft = 0; + didSnap = true; + } else if (vw - rect.right < threshold) { + snappedLeft = vw - rect.width; + didSnap = true; + } + + if (rect.top < threshold) { + snappedTop = 0; + didSnap = true; + } else if (vh - rect.bottom < threshold) { + snappedTop = vh - rect.height; + didSnap = true; + } + + if (didSnap) { + this.element.style.transition = 'left 0.2s ease, top 0.2s ease'; + this.element.style.left = `${snappedLeft}px`; + this.style.top = `${snappedTop}px`; + + const onTransitionEnd = () => { + this.element.style.transition = 'none'; + this.element.removeEventListener('transitionend', onTransitionEnd); + }; + this.element.addEventListener('transitionend', onTransitionEnd); + } else { + this.element.style.transition = 'none'; + } } /** * 统一的错误展示(替换标题与歌词区域) From b8fb5c55c390c9cfb9b8e28711276f848841bb85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E6=96=B0=E7=82=80?= <2489083744@qq.com> Date: Thu, 11 Jun 2026 00:19:01 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(drag):=20=E5=A2=9E=E5=BC=BA=E6=8B=96?= =?UTF-8?q?=E6=8B=BD=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E8=A7=A6?= =?UTF-8?q?=E6=91=B8=E3=80=81=E8=BE=B9=E7=BC=98=E5=90=B8=E9=99=84=E5=B9=B6?= =?UTF-8?q?=E4=B8=8E=E7=A9=BA=E9=97=B2=E7=8A=B6=E6=80=81=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netease-mini-player-v2-dev.js | 88 ++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/netease-mini-player-v2-dev.js b/netease-mini-player-v2-dev.js index 4295874..a3db061 100644 --- a/netease-mini-player-v2-dev.js +++ b/netease-mini-player-v2-dev.js @@ -673,22 +673,33 @@ class NeteaseMiniPlayer { } } /** - * 计算当前停靠方向 - * @returns {'left'|'right'} 返回停靠侧边枚举 + * 计算当前空闲停靠方向 + * @returns {'left'|'right'|null} 返回停靠侧边枚举,用户手动拖拽后返回 null * @description - * 1. 根据配置中的position值判断停靠方向 - * 2. 左上角和左下角返回'left' - * 3. 右上角和右下角返回'right' - * 4. 其他位置默认返回'right' + * 1. 若用户已手动拖拽过播放器(userDragged === true),直接返回 null, + * 阻止 triggerFadeOut 添加 docked-* 类及 transform,避免覆盖拖拽后的 left/top 位置 + * 2. 未拖拽时根据配置中的 position 值判断停靠方向: + * - 'top-left' / 'bottom-left' → 返回 'left' + * - 'top-right' / 'bottom-right' → 返回 'right' + * 3. position 为 'static' 或其他未识别值时默认返回 'right' + * 4. 返回值被 triggerFadeOut / restoreOpacity 用于决定添加哪个方向的 + * docked-* / popping-* CSS 类,从而触发对应方向的侧边滑入/滑出动画 * @example + * // 初始配置为左上角,未拖拽 * player.config.position = 'top-left'; * player.getDockSide(); // 'left' - * + * + * // 初始配置为右下角,未拖拽 * player.config.position = 'bottom-right'; * player.getDockSide(); // 'right' + * + * // 用户手动拖拽过后,无论原始配置如何均返回 null + * player.userDragged = true; + * player.getDockSide(); // null * @private */ getDockSide() { + if (this.userDragged) return null; const pos = this.config.position; if (pos === 'top-left' || pos === 'bottom-left') return 'left'; if (pos === 'top-right' || pos === 'bottom-right') return 'right'; @@ -1741,7 +1752,7 @@ class NeteaseMiniPlayer { } } /** - * 可拖拽交互元素的CSS选择器,用于排除控件区域 + * 可拖拽交互元素的CSS选择器,用于精确排除控件区域 * @type {string} * @private */ @@ -1774,6 +1785,8 @@ class NeteaseMiniPlayer { */ setupDragAndDrop() { this.snapThreshold = 20; + this.snapMargin = 10; + this.userDragged = false; this.element.style.position = 'fixed'; const onDragStart = (e) => this._onDragStart(e); @@ -1782,6 +1795,7 @@ class NeteaseMiniPlayer { this._boundDragMove = (e) => this._onDragMove(e); this._boundDragEnd = (e) => this._onDragEnd(e); + this._boundTransitionEnd = null; } /** @@ -1798,12 +1812,15 @@ class NeteaseMiniPlayer { const point = e.touches ? e.touches[0] : e; if (!point) return; - if (e.type === 'touchstart') { - e.preventDefault(); - } else { - e.preventDefault(); + e.preventDefault(); + + // 清除可能残留的过渡结束监听器,防止与本次拖拽冲突 + if (this._boundTransitionEnd) { + this.element.removeEventListener('transitionend', this._boundTransitionEnd); + this._boundTransitionEnd = null; } + // 空闲状态联动:立即退出空闲/停靠态 this.clearIdleTimer(); this.isIdle = false; this.element.classList.remove( @@ -1812,12 +1829,14 @@ class NeteaseMiniPlayer { 'popping-left', 'popping-right' ); + // 拖拽体验优化 this.isDragging = true; this.element.classList.add('dragging'); this.element.style.transition = 'none'; this.element.style.zIndex = '9999'; this.element.style.cursor = 'grabbing'; + // 记录起始状态 this.dragStartX = point.clientX; this.dragStartY = point.clientY; const rect = this.element.getBoundingClientRect(); @@ -1839,7 +1858,6 @@ class NeteaseMiniPlayer { _onDragMove = (e) => { if (!this.isDragging) return; - // 触摸时阻止页面滚动 if (e.type === 'touchmove') { e.preventDefault(); } @@ -1853,6 +1871,7 @@ class NeteaseMiniPlayer { let newLeft = this.initialLeft + deltaX; let newTop = this.initialTop + deltaY; + // 视口边界约束 const vw = window.innerWidth; const vh = window.innerHeight; const pw = this.element.offsetWidth; @@ -1880,19 +1899,25 @@ class NeteaseMiniPlayer { document.removeEventListener('touchend', this._boundDragEnd); document.removeEventListener('touchcancel', this._boundDragEnd); + // 恢复拖拽视觉状态 this.element.classList.remove('dragging'); this.element.style.zIndex = ''; this.element.style.cursor = ''; + // 标记用户已手动拖拽,禁用基于 data-position 的自动侧边停靠 + this.userDragged = true; + + // 执行边缘吸附 this.snapToEdge(); + // 最小化状态下重启空闲计时器(仅控制透明度淡出,不再触发侧边停靠) if (this.isMinimized) { this.startIdleTimer(); } } /** - * 边缘吸附 检测播放器与视口四边的距离,小于阈值自动贴边 + * 边缘吸附:检测播放器与视口四边的距离,小于阈值时自动贴边(保留间距) * @returns {void} * @private */ @@ -1901,41 +1926,60 @@ class NeteaseMiniPlayer { const vw = window.innerWidth; const vh = window.innerHeight; const threshold = this.snapThreshold || 20; + const margin = this.snapMargin || 10; let snappedLeft = rect.left; let snappedTop = rect.top; let didSnap = false; if (rect.left < threshold) { - snappedLeft = 0; + snappedLeft = margin; didSnap = true; } else if (vw - rect.right < threshold) { - snappedLeft = vw - rect.width; + snappedLeft = vw - rect.width - margin; didSnap = true; } if (rect.top < threshold) { - snappedTop = 0; + snappedTop = margin; didSnap = true; } else if (vh - rect.bottom < threshold) { - snappedTop = vh - rect.height; + snappedTop = vh - rect.height - margin; didSnap = true; } if (didSnap) { this.element.style.transition = 'left 0.2s ease, top 0.2s ease'; this.element.style.left = `${snappedLeft}px`; - this.style.top = `${snappedTop}px`; + this.element.style.top = `${snappedTop}px`; - const onTransitionEnd = () => { + this._boundTransitionEnd = () => { this.element.style.transition = 'none'; - this.element.removeEventListener('transitionend', onTransitionEnd); + this.element.removeEventListener('transitionend', this._boundTransitionEnd); + this._boundTransitionEnd = null; }; - this.element.addEventListener('transitionend', onTransitionEnd); + this.element.addEventListener('transitionend', this._boundTransitionEnd); } else { this.element.style.transition = 'none'; } } + + /** + * 判断是否启用空闲透明度功能 + * @returns {boolean} + * @description + * 用户手动拖拽后禁用自动侧边停靠(docked-*), + * 但最小化时仍允许纯透明度淡出(idle 类)。 + * 若需完全禁用空闲效果,可将下方 isMinimized 判断改为 return false。 + * @private + */ + shouldEnableIdleOpacity() { + if (this.userDragged) { + // 拖拽后仅允许透明度淡出,triggerFadeOut 中 getDockSide 返回 null 即可跳过停靠 + return this.isMinimized === true; + } + return this.isMinimized === true; + } /** * 统一的错误展示(替换标题与歌词区域) * @param {string} message - 错误消息 From f819eace6c142666c321039355161b13c64e6308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E6=96=B0=E7=82=80?= <2489083744@qq.com> Date: Thu, 11 Jun 2026 22:58:11 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C=E4=B8=8E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netease-mini-player-v2-dev.js | 46 +++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/netease-mini-player-v2-dev.js b/netease-mini-player-v2-dev.js index a3db061..8599728 100644 --- a/netease-mini-player-v2-dev.js +++ b/netease-mini-player-v2-dev.js @@ -907,15 +907,13 @@ class NeteaseMiniPlayer { * await player.loadPlaylist('123456789'); */ async loadPlaylist(playlistId) { + this.userDragged = false; + 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; + 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 => ({ @@ -937,6 +935,8 @@ class NeteaseMiniPlayer { * await player.loadSingleSong('123456789'); */ async loadSingleSong(songId) { + this.userDragged = false; + const cacheKey = this.getCacheKey('song', songId); let songData = this.getCache(cacheKey); if (!songData) { @@ -1701,6 +1701,7 @@ class NeteaseMiniPlayer { toggleMinimize() { const isCurrentlyMinimized = this.element.classList.contains('minimized'); this.isMinimized = isCurrentlyMinimized; + if (!isCurrentlyMinimized) { this.element.classList.add('minimized'); this.isMinimized = true; @@ -1714,6 +1715,8 @@ class NeteaseMiniPlayer { this.element.classList.remove('idle', 'fading-in', 'fading-out', 'docked-left', 'docked-right', 'popping-left', 'popping-right'); this.startIdleTimer(); } else { + this.userDragged = false; + this.element.classList.remove('minimized'); this.isMinimized = false; if (this.elements.minimizeBtn) { @@ -1728,6 +1731,8 @@ class NeteaseMiniPlayer { this.element.classList.remove('idle', 'fading-in', 'fading-out', 'docked-left', 'docked-right', 'popping-left', 'popping-right'); } this.isIdle = false; + + this.element.classList.remove('docked-left', 'docked-right'); } } /** @@ -1784,8 +1789,9 @@ class NeteaseMiniPlayer { * @private */ setupDragAndDrop() { - this.snapThreshold = 20; - this.snapMargin = 10; + this.snapThreshold = parseInt(this.element.getAttribute('data-snap-threshold')) || 20; + this.snapMargin = parseInt(this.element.getAttribute('data-snap-margin')) || 10; + this.userDragged = false; this.element.style.position = 'fixed'; @@ -1808,19 +1814,15 @@ class NeteaseMiniPlayer { if (target.closest(NeteaseMiniPlayer.DRAG_EXCLUDE_SELECTOR)) { return; } - const point = e.touches ? e.touches[0] : e; if (!point) return; - e.preventDefault(); - // 清除可能残留的过渡结束监听器,防止与本次拖拽冲突 if (this._boundTransitionEnd) { this.element.removeEventListener('transitionend', this._boundTransitionEnd); this._boundTransitionEnd = null; } - // 空闲状态联动:立即退出空闲/停靠态 this.clearIdleTimer(); this.isIdle = false; this.element.classList.remove( @@ -1829,14 +1831,15 @@ class NeteaseMiniPlayer { 'popping-left', 'popping-right' ); - // 拖拽体验优化 + this.element.style.userSelect = 'none'; + this.element.style.webkitUserSelect = 'none'; + this.isDragging = true; this.element.classList.add('dragging'); this.element.style.transition = 'none'; this.element.style.zIndex = '9999'; this.element.style.cursor = 'grabbing'; - // 记录起始状态 this.dragStartX = point.clientX; this.dragStartY = point.clientY; const rect = this.element.getBoundingClientRect(); @@ -1899,18 +1902,19 @@ class NeteaseMiniPlayer { document.removeEventListener('touchend', this._boundDragEnd); document.removeEventListener('touchcancel', this._boundDragEnd); - // 恢复拖拽视觉状态 this.element.classList.remove('dragging'); this.element.style.zIndex = ''; this.element.style.cursor = ''; - // 标记用户已手动拖拽,禁用基于 data-position 的自动侧边停靠 this.userDragged = true; - // 执行边缘吸附 + this.element.setAttribute('data-position', 'static'); + + this.element.style.userSelect = ''; + this.element.style.webkitUserSelect = ''; + this.snapToEdge(); - // 最小化状态下重启空闲计时器(仅控制透明度淡出,不再触发侧边停靠) if (this.isMinimized) { this.startIdleTimer(); } @@ -1925,8 +1929,9 @@ class NeteaseMiniPlayer { const rect = this.element.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; - const threshold = this.snapThreshold || 20; - const margin = this.snapMargin || 10; + + const threshold = this.snapThreshold; + const margin = this.snapMargin; let snappedLeft = rect.left; let snappedTop = rect.top; @@ -1939,7 +1944,6 @@ class NeteaseMiniPlayer { snappedLeft = vw - rect.width - margin; didSnap = true; } - if (rect.top < threshold) { snappedTop = margin; didSnap = true;