import { ToastType, uiFeedbackService } from '../UiFeedbackService.ts';
import { CoverThemeExtractor } from './CoverTheme.ts';
import { SeekReactiveIndicator } from './SeekReactiveIndicator.ts';
import { pushPageHistory } from '../pageHistory.ts';
/**
* 音乐轨道接口
*/
interface MusicTrack {
id: string;
name: string;
singer: string;
cover: string;
musicSrc: string;
lyric: string;
durationSeconds: number;
}
/**
* 后端完整音乐模型(MusicResponse)
*/
interface RawMusicResponse {
id: string;
title: string;
artist: string;
coverUrl: string;
musicUrl: string;
lyricContent: string;
duration: string | number;
}
/**
* 歌词行接口
*/
interface LyricLine {
time: number;
text: string;
}
/**
* 播放器状态接口
*/
interface PlayerState {
mode: PlayMode;
trackId: string | null;
time: number;
showLyrics: boolean;
lyricsOnBackground: boolean;
}
/**
* 播放模式类型
*/
type PlayMode = 'order' | 'random' | 'single';
/**
* 跳转方向类型
*/
type SkipDirection = 'prev' | 'next';
/**
* DOM 元素缓存接口
*/
interface MusicPlayerElements {
mini: HTMLElement;
miniCover: HTMLElement;
miniStatus: HTMLElement;
miniProgress: SVGCircleElement;
panel: HTMLElement;
panelContent: HTMLElement | null;
backdrop: HTMLElement;
close: HTMLElement;
title: HTMLElement;
artist: HTMLElement;
cover: HTMLElement;
play: HTMLElement;
prev: HTMLElement;
next: HTMLElement;
seek: HTMLInputElement;
seekWrapper: HTMLElement;
seekProgress: HTMLElement;
seekBuffered: HTMLElement;
timeCurrent: HTMLElement;
timeTotal: HTMLElement;
mode: HTMLElement;
lrcToggle: HTMLElement;
listToggle: HTMLElement;
list: HTMLElement;
listItems: HTMLElement;
listCloseMobile: HTMLElement | null;
listContent: HTMLElement;
listEmpty: HTMLElement;
searchInput: HTMLInputElement | null;
searchClear: HTMLElement | null;
scrollToCurrent: HTMLElement | null;
lyricsContainer: HTMLElement;
lyricsInner: HTMLElement;
bgLyrics: HTMLElement;
bgLyricsInner: HTMLElement;
loading: HTMLElement;
}
/**
* 扩展 Window 接口以支持单例模式
*/
declare global {
interface Window {
__music_player_instance?: MusicPlayer;
}
}
/**
* 音乐播放器组件
* 功能包括:音乐播放控制、歌词显示、播放列表管理、状态持久化等
*/
export class MusicPlayer {
/**
* 音频播放器实例
*/
private readonly audio!: HTMLAudioElement;
/**
* 播放列表
*/
private playlist: MusicTrack[] = [];
/**
* 当前播放歌曲的索引
*/
private currentIndex: number = 0;
/**
* 是否正在播放
*/
private isPlaying: boolean = false;
/**
* 播放模式(顺序、随机、单曲循环)
*/
private playMode: PlayMode = 'order';
/**
* 已解析的歌词行数组
*/
private lyrics: LyricLine[] = [];
/**
* 是否在面板内显示歌词
*/
private showLyrics: boolean = false;
/**
* 是否在背景显示歌词
*/
private lyricsOnBackground: boolean = false;
/**
* 是否显示播放列表
*/
private showList!: boolean;
/**
* 是否正在拖动进度条
*/
private isSeeking: boolean = false;
/**
* 当前高亮的歌词行索引
*/
private currentLyricIndex: number = -1;
/**
* 设备是否支持悬停(非触摸设备)
*/
private readonly _supportsHover!: boolean;
/**
* 移动端布局断点
*/
private readonly _mobileBreakpoint: number = 768;
/**
* 当前是否为桌面端布局,用于避免键盘触发 resize 时误切换列表状态
*/
private _isDesktopLayout!: boolean;
/**
* 是否正在拖动面板(移动端手势)
*/
private _isPanelDragging: boolean = false;
/**
* 面板拖动开始时的 Y 坐标
*/
private _panelDragStartY: number = 0;
/**
* 是否正在显示加载动画
*/
private _isLoading: boolean = false;
/**
* 音频加载失败的重试次数
*/
private _retryCount: number = 0;
/**
* 最大重试次数
*/
private readonly _maxRetries: number = 3;
/**
* 重试超时时间(毫秒)
*/
private readonly _retryTimeout: number = 10000;
/**
* 重试计时器
*/
private _retryTimer: number | null = null;
/**
* 是否正在处理 hash 变化(防止循环触发)
*/
private _isHandlingHashChange: boolean = false;
/**
* 上次自动保存状态的时间戳
*/
private _lastAutoSaveTime: number = 0;
/**
* 当前音频文件是否已加载
*/
private _isAudioLoaded: boolean = false;
/**
* 已预加载的歌曲索引
*/
private _preloadedIndex: number = -1;
/**
* 预加载的音频对象
*/
private _preloadAudio: HTMLAudioElement | null = null;
/**
* 随机模式下预先确定的下一首歌曲索引
*/
private _nextRandomIndex: number = -1;
/**
* 当前显示的封面 URL(用于避免重复设置相同封面)
*/
private _currentCoverUrl: string = '';
/**
* 是否发生过用户主动交互(用于满足浏览器自动播放策略)
*/
private _hasUserInteracted: boolean = false;
/**
* 待恢复的播放时间(秒)
*/
private _pendingRestoreTime: number = 0;
/**
* 待恢复播放时间对应的轨道 ID
*/
private _pendingRestoreTrackId: string | null = null;
/**
* 迷你播放器圆形进度条的周长
*/
private readonly circumference: number = 213.6;
/**
* 进度指示器的频谱呼吸效果控制器
*/
private readonly _seekReactiveIndicator!: SeekReactiveIndicator;
/**
* 封面主题色提取器
*/
private readonly _coverTheme!: CoverThemeExtractor;
/**
* 主题色提取任务令牌,防止快速切歌时旧封面结果覆盖新封面
*/
private _coverThemeToken: number = 0;
/**
* DOM 元素缓存
*/
private el!: MusicPlayerElements;
/**
* 构造函数,实现单例模式
* 确保整个应用只有一个播放器实例
*/
constructor() {
if (window.__music_player_instance) {
return window.__music_player_instance;
}
window.__music_player_instance = this;
this.audio = new Audio();
this.audio.crossOrigin = 'anonymous';
this._seekReactiveIndicator = new SeekReactiveIndicator(this.audio);
this._coverTheme = new CoverThemeExtractor();
this._isDesktopLayout = window.innerWidth > this._mobileBreakpoint;
this.showList = this._isDesktopLayout;
this._supportsHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
this.init().then();
}
/**
* 初始化播放器
* 包括元素缓存、事件绑定、数据加载和 URL hash 监听
*/
private async init(): Promise<void> {
this.initElements();
this.bindEvents();
await this.fetchMusic();
window.addEventListener('hashchange', () => {
if (this._isHandlingHashChange) {
return;
}
const isOpen = window.location.hash === '#music-player';
const panelIsOpen = this.el.panel.classList.contains('music-player__panel--open');
if (isOpen && !panelIsOpen) {
this._isHandlingHashChange = true;
this.togglePanel(true);
this._isHandlingHashChange = false;
} else if (!isOpen && panelIsOpen) {
this._isHandlingHashChange = true;
this.togglePanel(false);
this._isHandlingHashChange = false;
}
});
if (window.location.hash === '#music-player') {
this.togglePanel(true);
}
}
/**
* 初始化并缓存所有 DOM 元素
* 减少重复的 DOM 查询操作,提高性能
*/
private initElements(): void {
this.el = {
mini: document.getElementById('mp-mini')!,
miniCover: document.getElementById('mp-mini-cover')!,
miniStatus: document.getElementById('mp-mini-status')!,
miniProgress: document.getElementById(
'mp-mini-progress'
)! as unknown as SVGCircleElement,
panel: document.getElementById('mp-panel')!,
panelContent: this.el?.panel
? this.el.panel.querySelector('.music-player__content')
: null,
backdrop: document.getElementById('mp-backdrop')!,
close: document.getElementById('mp-close')!,
title: document.getElementById('mp-title')!,
artist: document.getElementById('mp-artist')!,
cover: document.getElementById('mp-cover')!,
play: document.getElementById('mp-play')!,
prev: document.getElementById('mp-prev')!,
next: document.getElementById('mp-next')!,
seek: document.getElementById('mp-seek')! as HTMLInputElement,
seekWrapper: document.getElementById('mp-seek-wrapper')!,
seekProgress: document.getElementById('mp-seek-progress')!,
seekBuffered: document.getElementById('mp-seek-buffered')!,
timeCurrent: document.getElementById('mp-time-current')!,
timeTotal: document.getElementById('mp-time-total')!,
mode: document.getElementById('mp-mode')!,
lrcToggle: document.getElementById('mp-lrc-toggle')!,
listToggle: document.getElementById('mp-list-toggle')!,
list: document.getElementById('mp-list')!,
listItems: document.getElementById('mp-list-items')!,
listCloseMobile: document.getElementById('mp-list-close-mobile'),
listContent: document.getElementById('mp-list-content')!,
listEmpty: document.getElementById('mp-list-empty')!,
searchInput: document.getElementById('mp-search') as HTMLInputElement | null,
searchClear: document.getElementById('mp-search-clear'),
scrollToCurrent: document.getElementById('mp-scroll-to-current'),
lyricsContainer: document.getElementById('mp-lyrics-container')!,
lyricsInner: document.getElementById('mp-lyrics-inner')!,
bgLyrics: document.getElementById('mp-bg-lyrics')!,
bgLyricsInner: document.getElementById('mp-bg-lyrics-inner')!,
loading: document.getElementById('mp-loading')!,
};
const panel = document.getElementById('mp-panel');
if (panel) {
this.el.panelContent = panel.querySelector('.music-player__content');
}
this.el.mini.setAttribute('aria-label', '音乐播放器,点击播放');
this.el.play.setAttribute('aria-label', '播放');
this.el.prev.setAttribute('aria-label', '上一首');
this.el.next.setAttribute('aria-label', '下一首');
if (this.showList) {
this.toggleList(true);
}
this._seekReactiveIndicator.attach(this.el.seekWrapper);
}
/**
* 绑定所有事件监听器
* 包括:播放控制、进度条、键盘快捷键、触摸手势、音频事件等
*/
private bindEvents(): void {
let clickTimer: number | null = null;
let doubleClickHandled = false;
let lastClickTime = 0;
this.el.mini.addEventListener('click', () => {
this._hasUserInteracted = true;
const now = Date.now();
if (!this._supportsHover) {
if (now - lastClickTime < 300) {
lastClickTime = 0;
return;
}
lastClickTime = now;
this.togglePlay();
return;
}
if (clickTimer) {
clearTimeout(clickTimer);
}
if (doubleClickHandled) {
doubleClickHandled = false;
return;
}
clickTimer = window.setTimeout(() => {
if (!doubleClickHandled) {
this.togglePlay();
}
clickTimer = null;
}, 250);
});
this.el.mini.addEventListener('dblclick', (e) => {
this._hasUserInteracted = true;
if (!this._supportsHover) {
return;
}
e.preventDefault();
doubleClickHandled = true;
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
this.togglePanel(true);
setTimeout(() => {
doubleClickHandled = false;
}, 500);
});
let touchTimer: number | null = null;
const startTouch = () => {
touchTimer = window.setTimeout(() => {
this.togglePanel(true);
touchTimer = null;
lastClickTime = 0;
}, 600);
};
const endTouch = () => {
if (touchTimer) {
clearTimeout(touchTimer);
touchTimer = null;
}
};
this.el.mini.addEventListener('touchstart', startTouch, { passive: true });
this.el.mini.addEventListener('touchend', endTouch);
this.el.mini.addEventListener('touchmove', endTouch);
this.el.close.addEventListener('click', () => this.togglePanel(false));
this.el.backdrop.addEventListener('click', () => this.togglePanel(false));
this.el.play.addEventListener('click', () => this.togglePlay());
this.el.prev.addEventListener('click', () => {
this._hasUserInteracted = true;
this.skip('prev');
});
this.el.next.addEventListener('click', () => {
this._hasUserInteracted = true;
this.skip('next');
});
this.el.seek.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement;
const pct = Number(target.value) / 100;
this.el.timeCurrent.textContent = this.formatTime(this.audio.duration * pct);
this.el.seekProgress.style.width = `${pct * 100}%`;
this.isSeeking = true;
});
this.el.seek.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
const pct = Number(target.value) / 100;
if (this.audio.duration) {
this.audio.currentTime = this.audio.duration * pct;
}
this.isSeeking = false;
});
// 监听缓冲进度
this.audio.addEventListener('progress', () => this.updateBufferedProgress());
this.el.mode.addEventListener('click', () => this.cycleMode());
this.el.lrcToggle.addEventListener('click', () => {
if (!this.showLyrics && !this.lyricsOnBackground) {
this.showLyrics = true;
this.lyricsOnBackground = false;
} else if (this.showLyrics && !this.lyricsOnBackground) {
this.showLyrics = false;
this.lyricsOnBackground = true;
} else {
this.showLyrics = false;
this.lyricsOnBackground = false;
}
this.updateLyricsDisplay();
this.saveState();
});
this.el.listToggle.addEventListener('click', () => {
this.toggleList(!this.showList);
});
if (this.el.listCloseMobile) {
this.el.listCloseMobile.addEventListener('click', () => {
this.toggleList(false);
});
}
if (this.el.searchInput) {
this.el.searchInput.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement;
const value = target.value.trim();
this.renderList(value);
if (this.el.searchClear) {
this.el.searchClear.style.display = value ? 'flex' : 'none';
}
});
this.el.searchInput.addEventListener('focus', () => this.syncViewportHeight());
}
if (this.el.searchClear) {
const searchClear = this.el.searchClear;
searchClear.addEventListener('click', () => {
if (this.el.searchInput) {
this.el.searchInput.value = '';
this.el.searchInput.focus();
}
searchClear.style.display = 'none';
this.renderList('');
});
}
if (this.el.scrollToCurrent) {
this.el.scrollToCurrent.addEventListener('click', () => {
this.scrollToCurrentTrack();
});
}
this.bindPanelGestures();
this.bindKeyboardShortcuts();
this.syncViewportHeight();
window.addEventListener('resize', () => this.handleWindowResize());
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => this.syncViewportHeight());
window.visualViewport.addEventListener('scroll', () => this.syncViewportHeight());
}
this.audio.addEventListener('timeupdate', () => this.onTimeUpdate());
this.audio.addEventListener('ended', () => this.onEnded());
this.audio.addEventListener('durationchange', () => {
this.el.timeTotal.textContent = this.formatTime(this.audio.duration);
});
this.audio.addEventListener('play', () => this.updatePlayState(true));
this.audio.addEventListener('pause', () => this.updatePlayState(false));
this.audio.addEventListener('loadstart', () => this.showLoading());
this.audio.addEventListener('canplay', () => this.hideLoading());
this.audio.addEventListener('waiting', () => this.showLoading());
this.audio.addEventListener('playing', () => this.hideLoading());
this.audio.addEventListener('stalled', () => this.showLoading());
this.audio.addEventListener('error', (e) => this.handleAudioError(e));
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => this.play());
navigator.mediaSession.setActionHandler('pause', () => this.pause());
navigator.mediaSession.setActionHandler('previoustrack', () => {
this.skip('prev');
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
this.skip('next');
});
}
}
/**
* 从服务器获取推荐音乐列表
*/
private async fetchMusic(): Promise<void> {
try {
const res = await fetch('/Music/Recommend/v2');
if (!res.ok) {
uiFeedbackService.toast('Failed to load music');
return;
}
const source = (await res.json()) as RawMusicResponse[];
this.playlist = source.map((x) => this.normalizeTrack(x));
if (this.playlist.length > 0) {
this.renderList();
this.restoreState();
}
} catch (e) {
console.error(e);
this.el.title.textContent = '加载失败';
}
}
/**
* 将后端完整模型转换为前端播放模型
*/
private normalizeTrack(source: RawMusicResponse): MusicTrack {
return {
id: source.id || '',
name: source.title || '未知标题',
singer: source.artist || '未知歌手',
cover: source.coverUrl || '',
musicSrc: source.musicUrl || '',
lyric: source.lyricContent || '',
durationSeconds: this.parseDurationToSeconds(source.duration),
};
}
/**
* 解析后端时长为秒
*/
private parseDurationToSeconds(duration: string | number | undefined): number {
if (typeof duration === 'number') {
return duration > 0 ? duration : 0;
}
if (!duration || typeof duration !== 'string') {
return 0;
}
const parts = duration.split(':');
if (parts.length !== 3) {
return 0;
}
const hour = Number(parts[0]);
const minute = Number(parts[1]);
const second = Number(parts[2]);
if (!Number.isFinite(hour) || !Number.isFinite(minute) || !Number.isFinite(second)) {
return 0;
}
return hour * 3600 + minute * 60 + second;
}
/**
* 渲染播放列表
* @param filterText 搜索过滤文本,为空则显示全部
*/
private renderList(filterText: string = ''): void {
const filtered = filterText
? this.playlist.filter(
(track) =>
track.name.toLowerCase().includes(filterText.toLowerCase()) ||
track.singer.toLowerCase().includes(filterText.toLowerCase())
)
: this.playlist;
if (filtered.length === 0) {
this.el.listItems.style.display = 'none';
this.el.listEmpty.style.display = 'flex';
return;
}
this.el.listItems.style.display = 'block';
this.el.listEmpty.style.display = 'none';
this.el.listItems.innerHTML = filtered
.map((track) => {
const originalIndex = this.playlist.indexOf(track);
const isPlaying = originalIndex === this.currentIndex;
const playingIcon = this.isPlaying ? 'fa-volume-up' : 'fa-pause';
const activeClass = isPlaying ? 'music-player__item--active' : '';
const displayStyle = isPlaying ? '' : 'display: none;';
return `
<div class="music-player__item ${activeClass}" data-index="${originalIndex}">
<div class="music-player__item-index">${originalIndex + 1}</div>
<div class="music-player__item-meta">
<span class="music-player__item-title">
${this.highlightText(track.name, filterText)}
</span>
<span class="music-player__item-artist">
${this.highlightText(track.singer, filterText)}
</span>
</div>
<div class="music-player__item-playing" style="${displayStyle}">
<i class="fa ${playingIcon}"></i>
</div>
</div>
`;
})
.join('');
this.el.listItems.querySelectorAll('.music-player__item').forEach((item) => {
item.addEventListener('click', () => {
const htmlItem = item as HTMLElement;
const index = parseInt(htmlItem.dataset.index || '0');
this.loadTrack(index, true);
});
});
}
/**
* 高亮搜索匹配的文本
* @param text 原始文本
* @param search 搜索关键词
* @returns 包含高亮标记的 HTML 字符串
*/
private highlightText(text: string, search: string): string {
if (!search) {
return text;
}
const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedSearch})`, 'gi');
return text.replace(regex, '<mark class="music-player__highlight">$1</mark>');
}
/**
* 更新列表中活动项的状态
* 仅更新样式和图标,不重新渲染整个列表
*/
private updateActiveListItem(): void {
const items = this.el.listItems.querySelectorAll('.music-player__item');
const playingIcon = this.isPlaying ? 'fa-volume-up' : 'fa-pause';
items.forEach((item) => {
const htmlItem = item as HTMLElement;
const index = parseInt(htmlItem.dataset.index || '0');
const isActive = index === this.currentIndex;
const playingIndicator = item.querySelector('.music-player__item-playing');
const playingIconEl = item.querySelector('.music-player__item-playing i');
if (isActive) {
htmlItem.classList.add('music-player__item--active');
if (playingIndicator) {
(playingIndicator as HTMLElement).style.display = '';
}
if (playingIconEl) {
playingIconEl.className = `fa ${playingIcon}`;
}
} else {
htmlItem.classList.remove('music-player__item--active');
if (playingIndicator) {
(playingIndicator as HTMLElement).style.display = 'none';
}
}
});
}
/**
* 滚动到当前播放的歌曲
* 清除搜索过滤,定位到当前播放项并添加高亮动画
*/
private scrollToCurrentTrack(): void {
if (this.el.searchInput && this.el.searchInput.value) {
this.el.searchInput.value = '';
if (this.el.searchClear) {
this.el.searchClear.style.display = 'none';
}
this.renderList('');
}
const activeItem = this.el.list.querySelector('.music-player__item--active');
if (activeItem && this.el.listContent) {
const htmlActiveItem = activeItem as HTMLElement;
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
htmlActiveItem.style.animation = 'music-item-highlight 0.6s ease';
setTimeout(() => {
htmlActiveItem.style.animation = '';
}, 600);
}
}
/**
* 加载指定索引的音乐轨道
* @param index 轨道索引
* @param autoPlay 是否自动播放,默认 true
* @param skipSave 是否跳过保存状态,默认 false
*/
private loadTrack(index: number, autoPlay: boolean = true, skipSave: boolean = false): void {
if (!this.playlist[index]) {
return;
}
this.currentIndex = index;
const track = this.playlist[index];
this._retryCount = 0;
if (this._retryTimer) {
clearTimeout(this._retryTimer);
this._retryTimer = null;
}
// 清理预加载状态(如果不是从预加载切换过来的)
if (this._preloadedIndex !== index) {
if (this._preloadAudio) {
this._preloadAudio.src = '';
this._preloadAudio = null;
}
this._preloadedIndex = -1;
}
// 清除随机索引,因为已经加载了新歌曲
this._nextRandomIndex = -1;
// 延迟加载:不立即设置 audio.src,只在播放时加载
this._isAudioLoaded = false;
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
if (this._pendingRestoreTrackId !== track.id) {
this._pendingRestoreTime = 0;
this._pendingRestoreTrackId = null;
}
this.el.title.textContent = track.name;
this.el.artist.textContent = track.singer;
// 仅在封面 URL 变化时才更新,避免重复请求
if (this._currentCoverUrl !== track.cover) {
const coverUrl = `url('${track.cover}')`;
this.el.cover.style.backgroundImage = coverUrl;
this.el.miniCover.style.backgroundImage = coverUrl;
this._currentCoverUrl = track.cover;
this.applyCoverTheme(track.cover);
}
// 优化列表渲染:只在有搜索条件或列表为空时才重新渲染
const currentSearch = this.el.searchInput ? this.el.searchInput.value.trim() : '';
if (currentSearch || this.el.listItems.children.length === 0) {
this.renderList(currentSearch);
} else {
// 只更新高亮状态,不重新渲染整个列表
this.updateActiveListItem();
}
this.parseLyrics(track.lyric);
this.currentLyricIndex = -1;
this.el.timeTotal.textContent = this.formatTime(track.durationSeconds);
const initialTime = this._pendingRestoreTrackId === track.id ? this._pendingRestoreTime : 0;
if (initialTime > 0 && track.durationSeconds > 0) {
const safeTime = Math.min(initialTime, track.durationSeconds);
this.el.timeCurrent.textContent = this.formatTime(safeTime);
this.el.seek.value = ((safeTime / track.durationSeconds) * 100).toString();
const offset =
this.circumference - (safeTime / track.durationSeconds) * this.circumference;
this.el.miniProgress.style.strokeDashoffset = offset.toString();
} else if (initialTime > 0) {
this.el.timeCurrent.textContent = this.formatTime(initialTime);
this.el.seek.value = '0';
this.el.miniProgress.style.strokeDashoffset = this.circumference.toString();
} else {
this.el.timeCurrent.textContent = '0:00';
this.el.seek.value = '0';
this.el.miniProgress.style.strokeDashoffset = this.circumference.toString();
}
if (autoPlay) {
this.play();
} else {
this.updatePlayState(false);
}
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.name,
artist: track.singer,
artwork: [{ src: String(track.cover), sizes: '512x512', type: 'image/jpeg' }],
});
}
if (!skipSave) {
this.saveState();
}
const isPanelOpen = this.el.panel.classList.contains('music-player__panel--open');
if (window.innerWidth > 768 && isPanelOpen) {
requestAnimationFrame(() => {
const activeItem = this.el.list.querySelector('.music-player__item--active');
if (activeItem && this.el.listContent) {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}
}
/**
* 根据封面异步提取并应用主题色
* 提取失败(CORS、灰白封面等)时回退到默认主题
* @param coverUrl 封面地址
*/
private applyCoverTheme(coverUrl: string): void {
const token = ++this._coverThemeToken;
this._coverTheme.extract(coverUrl).then((colors) => {
if (token !== this._coverThemeToken) {
return;
}
this._coverTheme.apply(colors);
});
}
/**
* 播放音频
* 延迟加载:只在真正播放时才加载音频文件
*/
private play(): void {
if (!this._hasUserInteracted) {
return;
}
// 如果音频还未加载,先加载
if (!this._isAudioLoaded) {
const track = this.playlist[this.currentIndex];
if (track) {
this.audio.src = track.musicSrc;
this._isAudioLoaded = true;
const pendingTime =
this._pendingRestoreTrackId === track.id ? this._pendingRestoreTime : 0;
if (pendingTime > 0) {
const onLoadedMetadata = () => {
try {
const maxDuration = this.audio.duration || track.durationSeconds;
if (maxDuration > 0) {
this.audio.currentTime = Math.min(pendingTime, maxDuration);
}
this._lastAutoSaveTime = Date.now();
} catch (e) {
console.warn('Failed to restore time on play:', e);
}
this._pendingRestoreTime = 0;
this._pendingRestoreTrackId = null;
this.audio.removeEventListener('loadedmetadata', onLoadedMetadata);
};
this.audio.addEventListener('loadedmetadata', onLoadedMetadata);
}
}
}
const p = this.audio.play();
if (p) {
p.catch((e) => {
if (e instanceof Error && e.name === 'NotAllowedError') {
return;
}
console.warn('Playback prevented:', e);
});
}
}
/**
* 暂停音频
*/
private pause(): void {
this.audio.pause();
}
/**
* 切换播放/暂停状态
*/
private togglePlay(): void {
this._hasUserInteracted = true;
if (this.audio.paused) {
this.play();
} else {
this.pause();
}
}
/**
* 更新播放状态的 UI
* 包括按钮图标、动画、列表项状态等
* @param isPlaying 是否正在播放
*/
private updatePlayState(isPlaying: boolean): void {
this.isPlaying = isPlaying;
const iconClass = isPlaying ? 'fa-pause' : 'fa-play';
this.el.play.innerHTML = `<i class="fa ${iconClass}"></i>`;
this.el.miniStatus.innerHTML = `<i class="fa ${iconClass}"></i>`;
const miniLabel = isPlaying
? '音乐播放器,正在播放,点击暂停'
: '音乐播放器,已暂停,点击播放';
this.el.mini.setAttribute('aria-label', miniLabel);
this.el.play.setAttribute('aria-label', isPlaying ? '暂停' : '播放');
if (isPlaying) {
this.el.cover.classList.add('music-player__cover--rotating');
this.el.miniCover.classList.add('music-player__cover--rotating');
this.el.mini.classList.add('music-player__mini--playing');
this._seekReactiveIndicator.start();
} else {
this.el.cover.classList.remove('music-player__cover--rotating');
this.el.miniCover.classList.remove('music-player__cover--rotating');
this.el.mini.classList.remove('music-player__mini--playing');
this._seekReactiveIndicator.stop();
}
const activeItem = this.el.list.querySelector('.music-player__item--active');
if (activeItem) {
const playingIndicator = activeItem.querySelector('.music-player__item-playing i');
if (playingIndicator) {
playingIndicator.className = isPlaying ? 'fa fa-volume-up' : 'fa fa-pause';
}
}
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
}
}
/**
* 跳转到上一首或下一首
* 根据播放模式决定跳转逻辑(顺序、随机)
* @param direction 跳转方向 'prev' 或 'next'
*/
private skip(direction: SkipDirection): void {
let nextIndex = this.currentIndex;
if (this.playMode === 'random') {
if (direction === 'next' && this._nextRandomIndex !== -1) {
// 如果是下一首且已经预先确定了随机索引,使用它
nextIndex = this._nextRandomIndex;
// 使用后清除
this._nextRandomIndex = -1;
} else if (this.playlist.length > 1) {
// 否则生成一个新的随机索引
do {
nextIndex = Math.floor(Math.random() * this.playlist.length);
} while (nextIndex === this.currentIndex);
// 清除旧的预测
this._nextRandomIndex = -1;
}
} else {
if (direction === 'next') {
nextIndex = (this.currentIndex + 1) % this.playlist.length;
} else {
nextIndex = (this.currentIndex - 1 + this.playlist.length) % this.playlist.length;
}
}
this.loadTrack(nextIndex, true);
}
/**
* 获取下一首歌曲的索引
* 根据播放模式返回不同的索引
* @returns 下一首歌曲的索引
*/
private getNextTrackIndex(): number {
if (this.playMode === 'single') {
return this.currentIndex;
}
if (this.playMode === 'random') {
// 随机模式:如果已经预先确定了下一首,返回它
if (this._nextRandomIndex !== -1) {
return this._nextRandomIndex;
}
// 否则,现在生成一个随机索引
if (this.playlist.length > 1) {
let nextIndex: number;
do {
nextIndex = Math.floor(Math.random() * this.playlist.length);
} while (nextIndex === this.currentIndex);
this._nextRandomIndex = nextIndex;
return nextIndex;
}
return this.currentIndex;
}
// 顺序播放
return (this.currentIndex + 1) % this.playlist.length;
}
/**
* 音频播放结束事件处理
* 根据播放模式决定是否循环或跳转下一首
*/
private onEnded(): void {
if (this.playMode === 'single') {
this.audio.currentTime = 0;
this.play();
} else {
this.skip('next');
}
}
/**
* 音频时间更新事件处理
* 更新进度条、时间显示、歌词同步,定期自动保存进度
* 智能预加载
*/
private onTimeUpdate(): void {
if (!this.isSeeking) {
// 如果音频未加载,但有待恢复的进度,使用待恢复的时间显示UI
const currentTrack = this.playlist[this.currentIndex];
const shouldUsePendingTime =
!this._isAudioLoaded &&
this._pendingRestoreTime > 0 &&
this._pendingRestoreTrackId === currentTrack?.id;
const cur = shouldUsePendingTime
? this._pendingRestoreTime
: this.audio.currentTime || 0;
const dur =
shouldUsePendingTime && currentTrack
? currentTrack.durationSeconds
: this.audio.duration || 1;
const pct = (cur / dur) * 100;
this.el.seek.value = isFinite(pct) ? pct.toString() : '0';
this.el.seekProgress.style.width = isFinite(pct) ? `${pct}%` : '0%';
this.el.timeCurrent.textContent = this.formatTime(cur);
if (isFinite(dur) && dur > 0) {
const offset = this.circumference - (cur / dur) * this.circumference;
this.el.miniProgress.style.strokeDashoffset = offset.toString();
}
}
this.syncLyrics(this.audio.currentTime);
const now = Date.now();
if (!this._lastAutoSaveTime) {
this._lastAutoSaveTime = now;
}
if (now - this._lastAutoSaveTime >= 5000) {
this.saveState();
this._lastAutoSaveTime = now;
}
// 智能预加载:当歌曲播放到剩余10秒时,预加载下一首
this.handlePreload();
}
/**
* 处理音频预加载
* 当歌曲接近结束时,预加载下一首
* 随机播放模式下会提前确定下一首随机歌曲
*/
private handlePreload(): void {
const cur = this.audio.currentTime || 0;
const dur = this.audio.duration || 0;
const remaining = dur - cur;
// 剩余时间大于10秒,或者时长无效,不预加载
if (remaining > 10 || !isFinite(remaining) || remaining <= 0) {
return;
}
const nextIndex = this.getNextTrackIndex();
// 已经预加载过该歌曲,跳过
if (nextIndex === this._preloadedIndex) {
return;
}
// 单曲循环且已加载,跳过
if (nextIndex === this.currentIndex && this._isAudioLoaded) {
return;
}
const nextTrack = this.playlist[nextIndex];
if (!nextTrack) {
return;
}
// 开始预加载
if (this._preloadAudio) {
this._preloadAudio.src = '';
this._preloadAudio = null;
}
this._preloadAudio = new Audio();
this._preloadAudio.preload = 'auto';
this._preloadAudio.src = nextTrack.musicSrc;
this._preloadedIndex = nextIndex;
}
/**
* 循环切换播放模式
* 顺序 → 随机 → 单曲循环 → 顺序
*/
private cycleMode(): void {
const modes: PlayMode[] = ['order', 'random', 'single'];
const modeNames: Record<PlayMode, string> = {
order: '顺序播放',
random: '随机播放',
single: '单曲循环',
};
const icons: Record<PlayMode, string> = {
order: 'fa-sort-amount-asc',
random: 'fa-random',
single: 'fa-retweet',
};
let idx = modes.indexOf(this.playMode);
idx = (idx + 1) % modes.length;
this.playMode = modes[idx];
const newTitle = modeNames[this.playMode];
this.el.mode.innerHTML = `<i class="fa ${icons[this.playMode]}"></i>`;
this.el.mode.setAttribute('data-original-title', newTitle);
this.el.mode.removeAttribute('title');
uiFeedbackService.showTooltip(this.el.mode, newTitle);
// 清理预加载状态,因为播放模式变了
if (this._preloadAudio) {
this._preloadAudio.src = '';
this._preloadAudio = null;
}
this._preloadedIndex = -1;
this._nextRandomIndex = -1;
this.saveState();
}
/**
* 切换播放列表的显示/隐藏
* @param show 是否显示列表
*/
private toggleList(show: boolean): void {
this.showList = show;
this.el.list.classList.toggle('music-player__list--visible', this.showList);
this.el.listToggle.classList.toggle('music-player__text-btn--active', this.showList);
if (this.el.listCloseMobile) {
if (window.innerWidth <= this._mobileBreakpoint) {
this.el.listCloseMobile.style.display = show ? 'flex' : 'none';
} else {
this.el.listCloseMobile.style.display = 'none';
}
}
if (this.showList) {
const activeItem = this.el.list.querySelector('.music-player__item--active');
if (activeItem) {
setTimeout(() => {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}
if (this.el.panelContent) {
this.el.panelContent.classList.toggle('music-player__content--list-open', show);
}
}
/**
* 同步移动端真实可视高度,避免软键盘弹出后 fixed 面板仍按布局视口计算
*/
private syncViewportHeight(): void {
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
this.el.panel.style.setProperty('--music-player-viewport-height', `${viewportHeight}px`);
const keyboardOffset = window.visualViewport
? Math.max(0, window.innerHeight - window.visualViewport.height)
: 0;
const isKeyboardOpen =
window.innerWidth <= this._mobileBreakpoint && this.showList && keyboardOffset > 80;
this.el.panel.classList.toggle('music-player__panel--keyboard-open', isKeyboardOpen);
}
/**
* 仅在桌面/移动断点发生变化时同步列表状态,避免手机键盘触发 resize 关闭列表
*/
private handleWindowResize(): void {
this.syncViewportHeight();
const isDesktop = window.innerWidth > this._mobileBreakpoint;
if (isDesktop === this._isDesktopLayout) {
return;
}
this._isDesktopLayout = isDesktop;
if (isDesktop && !this.showList) {
this.toggleList(true);
} else if (!isDesktop && this.showList) {
this.toggleList(false);
}
}
/**
* 切换音乐面板的显示/隐藏
* 同步处理 URL hash、滚动锁定等
* @param show 是否显示面板
*/
private togglePanel(show: boolean): void {
if (show) {
this.el.panel.classList.add('music-player__panel--open');
const shouldSetHash =
!this._isHandlingHashChange && window.location.hash !== '#music-player';
if (shouldSetHash) {
pushPageHistory('#music-player');
}
if (window.innerWidth <= this._mobileBreakpoint) {
document.body.style.overflow = 'hidden';
}
} else {
this.el.panel.classList.remove('music-player__panel--open');
if (this.el.panelContent) {
this.el.panelContent.style.transform = '';
}
const shouldClearHash =
!this._isHandlingHashChange && window.location.hash === '#music-player';
if (shouldClearHash) {
pushPageHistory(window.location.pathname + window.location.search);
}
document.body.style.overflow = '';
}
}
/**
* 绑定键盘快捷键
* 空格:播放/暂停
* 左右箭头:快进/快退 5 秒
* ESC:关闭面板
*/
private bindKeyboardShortcuts(): void {
document.addEventListener('keydown', (e) => {
const isPanelOpen = this.el.panel.classList.contains('music-player__panel--open');
if (!isPanelOpen) {
return;
}
const target = e.target as HTMLElement;
const tag = target.tagName ? target.tagName.toLowerCase() : '';
if (tag === 'input' || tag === 'textarea') {
return;
}
if (e.key === ' ') {
e.preventDefault();
this.togglePlay();
}
if (e.key === 'ArrowRight') {
e.preventDefault();
if (isFinite(this.audio.duration)) {
this.audio.currentTime = Math.min(
this.audio.currentTime + 5,
this.audio.duration
);
}
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
this.audio.currentTime = Math.max(this.audio.currentTime - 5, 0);
}
if (e.key === 'Escape') {
e.preventDefault();
this.togglePanel(false);
}
});
}
/**
* 绑定面板手势操作(移动端)
* 支持下滑关闭面板
*/
private bindPanelGestures(): void {
if (!this.el.panelContent) {
return;
}
const onTouchStart = (e: TouchEvent) => {
const isPanelOpen = this.el.panel.classList.contains('music-player__panel--open');
if (window.innerWidth > 768 || !isPanelOpen || this.showList) {
return;
}
this._isPanelDragging = true;
this._panelDragStartY = e.touches[0].clientY;
if (this.el.panelContent) {
this.el.panelContent.style.transition = 'none';
}
};
const onTouchMove = (e: TouchEvent) => {
if (!this._isPanelDragging || !this.el.panelContent) {
return;
}
const deltaY = e.touches[0].clientY - this._panelDragStartY;
if (deltaY <= 0) {
return;
}
this.el.panelContent.style.transform = `translateY(${deltaY}px)`;
};
const onTouchEnd = (e: TouchEvent) => {
if (!this._isPanelDragging || !this.el.panelContent) {
return;
}
this._isPanelDragging = false;
const endY = e.changedTouches[0].clientY;
const deltaY = Math.max(0, endY - this._panelDragStartY);
this.el.panelContent.style.transition = '';
if (deltaY > 120) {
this.togglePanel(false);
return;
}
this.el.panelContent.style.transform = '';
};
this.el.panelContent.addEventListener('touchstart', onTouchStart, {
passive: true,
});
this.el.panelContent.addEventListener('touchmove', onTouchMove, {
passive: true,
});
this.el.panelContent.addEventListener('touchend', onTouchEnd, {
passive: true,
});
this.el.panelContent.addEventListener('touchcancel', onTouchEnd, {
passive: true,
});
}
/**
* 保存播放器状态到 localStorage
* 包括播放模式、当前轨道、播放进度、歌词显示状态等
*/
private saveState(): void {
const currentTrack = this.playlist[this.currentIndex];
if (!currentTrack) {
return;
}
// 如果音频已加载,使用实际播放时间;否则使用待恢复的时间(避免覆盖进度)
let currentTime = 0;
if (this._isAudioLoaded) {
currentTime = this.audio.currentTime;
} else if (
this._pendingRestoreTrackId === currentTrack.id &&
this._pendingRestoreTime > 0
) {
currentTime = this._pendingRestoreTime;
}
const state: PlayerState = {
mode: this.playMode,
trackId: currentTrack.id,
time: currentTime,
showLyrics: this.showLyrics,
lyricsOnBackground: this.lyricsOnBackground,
};
localStorage.setItem('dpz_music_player_state', JSON.stringify(state));
}
/**
* 从 localStorage 恢复播放器状态
* 恢复上次的播放模式、歌曲、进度和歌词显示状态
*/
private restoreState(): void {
try {
const saved = localStorage.getItem('dpz_music_player_state');
if (!saved) {
this.loadTrack(0, false);
return;
}
const state = JSON.parse(saved) as Partial<PlayerState>;
// 恢复播放模式
if (state.mode && ['order', 'random', 'single'].includes(state.mode)) {
this.playMode = state.mode;
const icons: Record<PlayMode, string> = {
order: 'fa-sort-amount-asc',
random: 'fa-random',
single: 'fa-retweet',
};
const modeNames: Record<PlayMode, string> = {
order: '顺序播放',
random: '随机播放',
single: '单曲循环',
};
this.el.mode.innerHTML = `<i class="fa ${icons[this.playMode]}"></i>`;
this.el.mode.setAttribute('title', modeNames[this.playMode]);
if (this.el.mode.hasAttribute('data-original-title')) {
this.el.mode.setAttribute('data-original-title', modeNames[this.playMode]);
}
}
// 恢复歌词显示设置
if (typeof state.showLyrics === 'boolean') {
this.showLyrics = state.showLyrics;
}
if (typeof state.lyricsOnBackground === 'boolean') {
this.lyricsOnBackground = state.lyricsOnBackground;
}
// 查找上次播放的歌曲
let index = 0;
if (state.trackId) {
const foundIndex = this.playlist.findIndex((t) => t.id === state.trackId);
if (foundIndex !== -1) {
index = foundIndex;
}
}
// 恢复播放进度
const savedTime = typeof state.time === 'number' ? state.time : 0;
if (savedTime > 0) {
this._pendingRestoreTime = savedTime;
this._pendingRestoreTrackId = this.playlist[index]?.id || null;
}
this.loadTrack(index, false, true);
this.updateLyricsDisplay();
} catch (e) {
console.error('Failed to restore music player state', e);
this.loadTrack(0, false);
}
}
/**
* 格式化时间(秒)为 M:SS 格式
* @param seconds 秒数
* @returns 格式化后的时间字符串,如 "3:45"
*/
private formatTime(seconds: number): string {
if (!isFinite(seconds)) {
return '0:00';
}
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
}
/**
* 显示加载动画
*/
private showLoading(): void {
if (this._isLoading) {
return;
}
this._isLoading = true;
if (this.el.loading) {
this.el.loading.style.display = 'flex';
}
}
/**
* 隐藏加载动画
*/
private hideLoading(): void {
if (!this._isLoading) {
return;
}
this._isLoading = false;
if (this.el.loading) {
this.el.loading.style.display = 'none';
}
}
/**
* 处理音频加载错误
* 自动重试机制:失败后重试最多 3 次,每次重试间隔 1 秒
* @param e 错误事件
*/
private async handleAudioError(e: Event): Promise<void> {
if (!this._isAudioLoaded || (!this.audio.currentSrc && !this.audio.src)) {
return;
}
console.error('音频加载错误', e);
const track = this.playlist[this.currentIndex];
if (!track) {
return;
}
if (this._retryTimer) {
clearTimeout(this._retryTimer);
this._retryTimer = null;
}
if (this._retryCount < this._maxRetries) {
if (!this._hasUserInteracted) {
return;
}
this._retryCount++;
const retryMsg = `加载失败,正在重试 (${this._retryCount}/${this._maxRetries})...`;
uiFeedbackService.toast(retryMsg, ToastType.Warning);
this._retryTimer = window.setTimeout(() => {
console.warn(`重试${this._retryCount}超时,尝试下一次重试`);
if (this._retryCount < this._maxRetries) {
this.audio.src = track.musicSrc;
this._isAudioLoaded = true;
this.play();
} else {
this.skipToNextAfterError(track);
}
}, this._retryTimeout);
await new Promise((resolve) => setTimeout(resolve, 1000));
this.audio.src = track.musicSrc;
this._isAudioLoaded = true;
this.play();
} else {
this.skipToNextAfterError(track);
}
}
/**
* 所有重试失败后自动跳到下一曲
* @param track 失败的音乐轨道
*/
private skipToNextAfterError(track: MusicTrack): void {
this._retryCount = 0;
if (this._retryTimer) {
clearTimeout(this._retryTimer);
this._retryTimer = null;
}
this.hideLoading();
uiFeedbackService.toast(`《${track.name}》加载失败,自动播放下一曲`, ToastType.Error);
setTimeout(() => {
this.skip('next');
}, 1000);
}
/**
* 解析 LRC 格式的歌词字符串
* 格式示例:[00:12.34]歌词内容
* @param lrcString LRC 格式的歌词字符串
*/
private parseLyrics(lrcString: string): void {
this.lyrics = [];
this.el.lyricsInner.innerHTML = '';
this.el.bgLyricsInner.innerHTML = '';
this.el.bgLyricsInner.style.transform = 'translateY(0)';
if (!lrcString) {
const html = '<div class="music-player__lyric-line">暂无歌词</div>';
this.el.lyricsInner.innerHTML = html;
this.el.bgLyricsInner.innerHTML = html;
return;
}
const lines = lrcString.split('\n');
const regex = /\[(\d{2}):(\d{2})\.(\d{2,3})](.*)/;
for (const line of lines) {
const match = regex.exec(line);
if (match) {
const min = parseInt(match[1]);
const sec = parseInt(match[2]);
const msStr = match[3];
const ms = msStr.length === 3 ? parseInt(msStr) : parseInt(msStr) * 10;
const time = min * 60 + sec + ms / 1000;
const text = match[4].trim();
if (text) {
this.lyrics.push({ time, text });
}
}
}
const noLyricsHtml = '<div class="music-player__lyric-line">纯音乐 / 暂无歌词</div>';
const lyricsHtml =
this.lyrics.length === 0
? noLyricsHtml
: this.lyrics
.map(
(l, i) =>
`<div class="music-player__lyric-line" data-index="${i}">${l.text}</div>`
)
.join('');
this.el.lyricsInner.innerHTML = lyricsHtml;
this.el.bgLyricsInner.innerHTML = lyricsHtml;
this.updateLyricsDisplay();
}
/**
* 更新歌词显示状态
* 根据用户设置切换面板内歌词、背景歌词或隐藏歌词
*/
private updateLyricsDisplay(): void {
const shouldShowInPanel = this.showLyrics && !this.lyricsOnBackground;
this.el.lyricsContainer.style.display = shouldShowInPanel ? 'block' : 'none';
this.el.cover.style.display = shouldShowInPanel ? 'none' : 'block';
this.el.bgLyrics.classList.toggle(
'music-player__bg-lyrics--visible',
this.lyricsOnBackground
);
const isActive = this.showLyrics || this.lyricsOnBackground;
this.el.lrcToggle.classList.toggle('music-player__text-btn--active', isActive);
if (this.lyricsOnBackground) {
this.syncLyrics(this.audio.currentTime || 0);
} else if (isActive) {
this.syncLyrics(this.audio.currentTime);
}
}
/**
* 更新缓冲进度显示
*/
private updateBufferedProgress(): void {
if (!this.audio.duration || !isFinite(this.audio.duration)) {
return;
}
const buffered = this.audio.buffered;
if (buffered.length > 0) {
// 获取最后一个缓冲区间的结束位置
const bufferedEnd = buffered.end(buffered.length - 1);
const bufferedPercent = (bufferedEnd / this.audio.duration) * 100;
this.el.seekBuffered.style.width = `${Math.min(bufferedPercent, 100)}%`;
}
}
/**
* 同步歌词到当前播放时间
* 高亮当前歌词行,并自动滚动到可视区域
* @param time 当前播放时间(秒)
*/
private syncLyrics(time: number): void {
const shouldSync = (this.showLyrics || this.lyricsOnBackground) && this.lyrics.length > 0;
if (!shouldSync) {
return;
}
let activeIdx = -1;
for (let i = 0; i < this.lyrics.length; i++) {
if (time >= this.lyrics[i].time) {
activeIdx = i;
} else {
break;
}
}
if (activeIdx === this.currentLyricIndex) {
return;
}
this.currentLyricIndex = activeIdx;
if (this.showLyrics && !this.lyricsOnBackground) {
const lines = this.el.lyricsInner.children;
for (const line of lines) {
line.classList.remove('music-player__lyric-line--current');
}
if (activeIdx >= 0 && lines[activeIdx]) {
const activeLine = lines[activeIdx] as HTMLElement;
activeLine.classList.add('music-player__lyric-line--current');
activeLine.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
if (this.lyricsOnBackground) {
const container = this.el.bgLyricsInner;
const lines = container.children;
for (const line of lines) {
line.classList.remove('music-player__lyric-line--current');
}
if (activeIdx >= 0 && lines[activeIdx]) {
const activeLine = lines[activeIdx] as HTMLElement;
activeLine.classList.add('music-player__lyric-line--current');
const offset = activeLine.offsetTop;
container.style.transform = `translateY(-${offset}px)`;
}
}
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
这是一个功能非常完整的Web音乐播放器类,让我为您详细解释一下这个代码的主要结构和功能:
整体架构
这是一个采用单例模式的音乐播放器类,确保整个应用只有一个播放器实例。播放器支持完整的音乐播放功能,包括播放控制、歌词显示、播放列表管理等。
核心功能模块
1. 数据接口定义
interface MusicTrack {
id: string;
name: string;
singer: string;
cover: string;
musicSrc: string;
lyric: string;
durationSeconds: number;
}
定义了音乐轨道的标准格式,以及后端数据格式转换接口。
2. 播放器核心状态
- 音频控制: 使用HTML5
AudioAPI - 播放列表管理: 支持顺序、随机、单曲循环三种模式
- 进度跟踪: 实时更新播放进度和时间显示
- 状态持久化: 自动保存和恢复播放状态到localStorage
3. 用户界面功能
迷你播放器
- 显示当前歌曲封面和播放状态
- 圆形进度条显示播放进度
- 单击播放/暂停,双击打开完整面板
完整播放面板
- 大封面显示和旋转动画
- 完整的播放控制按钮
- 进度条拖拽控制
- 歌词显示(面板内或背景显示)
- 播放列表管理
4. 高级特性
智能预加载
private handlePreload(): void {
const remaining = dur - cur;
if (remaining > 10) return; // 剩余10秒时开始预加载下一首
}
错误处理和重试机制
private async handleAudioError(): Promise<void> {
if (this._retryCount < this._maxRetries) {
// 最多重试3次,每次间隔1秒
await new Promise(resolve => setTimeout(resolve, 1000));
this.audio.src = track.musicSrc;
this.play();
}
}
歌词同步显示
- 支持LRC格式歌词解析
- 实时高亮当前歌词行
- 支持面板内滚动和背景滚动两种模式
5. 移动端优化
响应式布局
- 桌面端默认显示播放列表
- 移动端可折叠列表显示
- 软键盘适配处理
手势支持
- 长按迷你播放器打开面板
- 下滑手势关闭面板
- 触摸友好的控件设计
视口高度同步
private syncViewportHeight(): void {
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
this.el.panel.style.setProperty('--music-player-viewport-height', `${viewportHeight}px`);
}
6. 性能优化
DOM元素缓存
所有DOM元素在初始化时一次性缓存,避免重复查询:
private initElements(): void {
this.el = {
mini: document.getElementById('mp-mini')!,
play: document.getElementById('mp-play')!,
// ... 其他元素
};
}
延迟加载
音频文件只在真正播放时才加载,而不是切换歌曲时立即加载。
智能列表渲染
只在必要时重新渲染播放列表,平时只更新活动项状态。
7. 辅助功能
键盘快捷键
- 空格键: 播放/暂停
- 左右箭头: 快退/快进5秒
- ESC: 关闭面板
无障碍支持
- ARIA标签支持
- 语义化的按钮标签
- 屏幕阅读器友好
媒体会话集成
支持系统级媒体控制(如耳机按钮、锁屏控制等)。
技术亮点
- 单例模式: 确保全局唯一实例
- 状态管理: 完整的状态持久化和恢复
- 错误恢复: 自动重试和错误处理机制
- 性能优化: DOM缓存、延迟加载、智能预加载
- 用户体验: 平滑动画、手势支持、键盘快捷键
- 跨平台兼容: 桌面和移动端自适应
这是一个企业级的音乐播放器实现,代码结构清晰,功能完整,考虑了各种边界情况和用户体验细节。
AI 正在分析代码…
评论加载中...