Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 254 additions & 21 deletions netease-mini-player-v2-dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-*`属性为内部配置
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 => ({
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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');
}
}
/**
Expand All @@ -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;
}
/**
* 统一的错误展示(替换标题与歌词区域)
Expand Down