diff --git a/netease-mini-player-v2-dev.js b/netease-mini-player-v2-dev.js index 2383d44..8599728 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-*`属性为内部配置 @@ -668,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'; @@ -891,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 => ({ @@ -921,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) { @@ -1685,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; @@ -1698,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) { @@ -1712,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'); } } /** @@ -1736,20 +1757,232 @@ 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 = 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'; + + 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); + this._boundTransitionEnd = null; + } + + /** + * 统一拖拽开始处理(鼠标和触摸) + * @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; + e.preventDefault(); + + if (this._boundTransitionEnd) { + this.element.removeEventListener('transitionend', this._boundTransitionEnd); + this._boundTransitionEnd = null; + } + + this.clearIdleTimer(); + this.isIdle = false; + this.element.classList.remove( + 'idle', 'fading-in', 'fading-out', + 'docked-left', 'docked-right', + '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(); + 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.userDragged = true; + + this.element.setAttribute('data-position', 'static'); + + this.element.style.userSelect = ''; + this.element.style.webkitUserSelect = ''; + + 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; + const margin = this.snapMargin; + + let snappedLeft = rect.left; + let snappedTop = rect.top; + let didSnap = false; + + if (rect.left < threshold) { + snappedLeft = margin; + didSnap = true; + } else if (vw - rect.right < threshold) { + snappedLeft = vw - rect.width - margin; + didSnap = true; + } + if (rect.top < threshold) { + snappedTop = margin; + didSnap = true; + } else if (vh - rect.bottom < threshold) { + 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.element.style.top = `${snappedTop}px`; + + this._boundTransitionEnd = () => { + this.element.style.transition = 'none'; + this.element.removeEventListener('transitionend', this._boundTransitionEnd); + this._boundTransitionEnd = null; + }; + 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; } /** * 统一的错误展示(替换标题与歌词区域)