import {dialog} from "../dialog.js";

/**
 * 封装的音乐播放器组件
 * */
export class MusicPlayer {
    constructor() {
        // Singleton check
        if (window.__music_player_instance) {
            return window.__music_player_instance;
        }
        window.__music_player_instance = this;

        this.audio = new Audio();
        this.playlist = [];
        this.currentIndex = 0;
        this.isPlaying = false;
        this.isShuffled = false;
        this.playMode = 'order'; // order, random, single
        this.lyrics = [];
        this.showLyrics = false;
        this.lyricsOnBackground = false; // 歌词是否显示在页面背景
        this.showList = window.innerWidth > 768;
        this.isSeeking = false;
        this.currentLyricIndex = -1;

        // BEM Elements cache
        this.el = {};

        this.init();
    }

    async init() {
        // Initialize Elements
        this.initElements();

        // Bind events
        this.bindEvents();

        // Load data
        await this.fetchMusic();

        // 监听hash变化,支持浏览器前进后退
        this._isHandlingHashChange = false;
        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;
            }
        });

        // 初始化时检查hash
        if (window.location.hash === '#music-player') {
            this.togglePanel(true);
        }
    }

    initElements() {
        // Cache elements (HTML is now in _Layout.cshtml via _MusicPlayer.cshtml)
        this.el.mini = document.getElementById('mp-mini');
        this.el.miniCover = document.getElementById('mp-mini-cover');
        this.el.miniStatus = document.getElementById('mp-mini-status');
        this.el.miniProgress = document.getElementById('mp-mini-progress');

        this.el.panel = document.getElementById('mp-panel');
        this.el.backdrop = document.getElementById('mp-backdrop');
        this.el.close = document.getElementById('mp-close');

        this.el.title = document.getElementById('mp-title');
        this.el.artist = document.getElementById('mp-artist');
        this.el.cover = document.getElementById('mp-cover');

        this.el.play = document.getElementById('mp-play');
        this.el.prev = document.getElementById('mp-prev');
        this.el.next = document.getElementById('mp-next');

        this.el.seek = document.getElementById('mp-seek');
        this.el.timeCurrent = document.getElementById('mp-time-current');
        this.el.timeTotal = document.getElementById('mp-time-total');

        this.el.mode = document.getElementById('mp-mode');
        this.el.lrcToggle = document.getElementById('mp-lrc-toggle');
        this.el.listToggle = document.getElementById('mp-list-toggle');

        this.el.list = document.getElementById('mp-list');
        this.el.listItems = document.getElementById('mp-list-items');
        this.el.listCloseMobile = document.getElementById('mp-list-close-mobile');

        this.el.lyricsContainer = document.getElementById('mp-lyrics-container');
        this.el.lyricsInner = document.getElementById('mp-lyrics-inner');
        this.el.bgLyrics = document.getElementById('mp-bg-lyrics');
        this.el.bgLyricsInner = document.getElementById('mp-bg-lyrics-inner');

        // Progress ring circumference
        // r=34 -> 2*PI*34 = 213.628
        this.circumference = 213.6;

        // Initialize state
        if (this.showList) {
            this.toggleList(true);
        }
    }

    bindEvents() {
        // Mini Player Interactions
        let clickTimer = null;

        // Click: Toggle Play
        this.el.mini.addEventListener('click', () => {
            if (clickTimer) clearTimeout(clickTimer);
            
            clickTimer = setTimeout(() => {
                this.togglePlay();
                clickTimer = null;
            }, 300);
        });

        // Double Click: Open Panel
        this.el.mini.addEventListener('dblclick', (e) => {
            e.preventDefault();
            if (clickTimer) {
                clearTimeout(clickTimer);
                clickTimer = null;
            }
            this.togglePanel(true);
        });

        // Touch Long Press: Open Panel
        let touchTimer = null;
        const startTouch = () => {
            touchTimer = setTimeout(() => {
                this.togglePanel(true);
                touchTimer = null;
            }, 600); // 600ms long press
        };
        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));
        // Backdrop click only closes panel on mobile (or if we decide to show backdrop)
        this.el.backdrop.addEventListener('click', () => this.togglePanel(false));

        // Play Controls
        this.el.play.addEventListener('click', () => this.togglePlay());
        this.el.prev.addEventListener('click', () => this.skip('prev'));
        this.el.next.addEventListener('click', () => this.skip('next'));

        // Seek
        this.el.seek.addEventListener('input', (e) => {
            const pct = e.target.value / 100;
            this.el.timeCurrent.textContent = this.formatTime(this.audio.duration * pct);
            this.isSeeking = true;
        });
        this.el.seek.addEventListener('change', (e) => {
            const pct = e.target.value / 100;
            if (this.audio.duration) {
                this.audio.currentTime = this.audio.duration * pct;
            }
            this.isSeeking = false;
        });

        // Modes
        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;
            }

            // 更新UI
            this.updateLyricsDisplay();
            // 保存状态
            this.saveState();
        });
        this.el.listToggle.addEventListener('click', () => {
            this.toggleList(!this.showList);
        });

        // Mobile list close button
        if (this.el.listCloseMobile) {
            this.el.listCloseMobile.addEventListener('click', () => {
                this.toggleList(false);
            });
        }

        // Audio Events
        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('error', (e) => {
            console.error("Audio error", e);
        });

        // Media Session
        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'));
        }
    }

    async fetchMusic() {
        try {
            const res = await fetch('/Music/Recommend/v2');
            if (!res.ok) {
                dialog.toast('Failed to load music');
                return;
            }
            this.playlist = await res.json();

            if (this.playlist.length > 0) {
                this.renderList();
                this.restoreState();
            }
        } catch (e) {
            console.error(e);
            this.el.title.textContent = '加载失败';
        }
    }

    renderList() {
        this.el.listItems.innerHTML = this.playlist.map((track, index) => `
            <div class="music-player__item" data-index="${index}">
                <div class="music-player__item-index">${index + 1}</div>
                <div class="music-player__item-meta">
                    <span class="music-player__item-title">${track.name}</span>
                    <span class="music-player__item-artist">${track.singer}</span>
                </div>
            </div>
        `).join('');

        this.el.listItems.querySelectorAll('.music-player__item').forEach(item => {
            item.addEventListener('click', () => {
                const index = parseInt(item.dataset.index);
                this.loadTrack(index, true);
            });
        });
    }

    loadTrack(index, autoPlay = true, skipSave = false) {
        if (!this.playlist[index]) return;

        this.currentIndex = index;
        const track = this.playlist[index];

        this.audio.src = track.musicSrc;

        // Update UI
        this.el.title.textContent = track.name;
        this.el.artist.textContent = track.singer;
        this.el.cover.style.backgroundImage = `url('${track.cover}')`;
        this.el.miniCover.style.backgroundImage = `url('${track.cover}')`;

        // Active item in list
        const active = this.el.list.querySelector('.music-player__item--active');
        if (active) active.classList.remove('music-player__item--active');
        const newItem = this.el.list.querySelector(`.music-player__item[data-index="${index}"]`);
        if (newItem) {
            newItem.classList.add('music-player__item--active');
            if (this.showList) {
                newItem.scrollIntoView({behavior: 'smooth', block: 'nearest'});
            }
        }

        // Lyrics
        this.parseLyrics(track.lyric);
        this.currentLyricIndex = -1;

        if (autoPlay) {
            this.play();
        } else {
            this.updatePlayState(false);
        }

        // Update MediaSession
        if ('mediaSession' in navigator) {
            navigator.mediaSession.metadata = new MediaMetadata({
                title: track.name,
                artist: track.singer,
                artwork: [
                    {src: track.cover, sizes: '512x512', type: 'image/jpeg'}
                ]
            });
        }
        
        if (!skipSave) {
            this.saveState();
        }
    }

    play() {
        const p = this.audio.play();
        if (p) {
            p.catch(e => console.warn("Playback prevented:", e));
        }
    }

    pause() {
        this.audio.pause();
    }

    togglePlay() {
        if (this.audio.paused) this.play();
        else this.pause();
    }

    updatePlayState(isPlaying) {
        this.isPlaying = isPlaying;
        const iconClass = isPlaying ? 'fa-pause' : 'fa-play';

        // Main button
        this.el.play.innerHTML = `<i class="fa ${iconClass}"></i>`;

        // Mini status icon (just indicates status, no longer a button)
        this.el.miniStatus.innerHTML = `<i class="fa ${iconClass}"></i>`;

        if (isPlaying) {
            this.el.cover.classList.add('music-player__cover--rotating');
            this.el.miniCover.classList.add('music-player__cover--rotating');
        } else {
            this.el.cover.classList.remove('music-player__cover--rotating');
            this.el.miniCover.classList.remove('music-player__cover--rotating');
        }

        if ('mediaSession' in navigator) {
            navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused";
        }
    }

    skip(direction) {
        let nextIndex = this.currentIndex;
        if (this.playMode === 'random') {
            if (this.playlist.length > 1) {
                do {
                    nextIndex = Math.floor(Math.random() * this.playlist.length);
                } while (nextIndex === this.currentIndex);
            }
        } 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);
    }

    onEnded() {
        if (this.playMode === 'single') {
            this.audio.currentTime = 0;
            this.play();
        } else {
            this.skip('next');
        }
    }

    onTimeUpdate() {
        if (!this.isSeeking) {
            const cur = this.audio.currentTime || 0;
            const dur = this.audio.duration || 1;
            const pct = (cur / dur) * 100;

            this.el.seek.value = isFinite(pct) ? pct : 0;
            this.el.timeCurrent.textContent = this.formatTime(cur);

            // Mini progress ring
            if (isFinite(dur) && dur > 0) {
                const offset = this.circumference - (cur / dur) * this.circumference;
                this.el.miniProgress.style.strokeDashoffset = offset;
            }
        }

        this.syncLyrics(this.audio.currentTime);
        
        // 定期自动保存进度(每5秒保存一次,避免过于频繁)
        const now = Date.now();
        if (!this._lastAutoSaveTime) {
            this._lastAutoSaveTime = now;
        }
        if (now - this._lastAutoSaveTime >= 5000) {
            this.saveState();
            this._lastAutoSaveTime = now;
        }
    }

    cycleMode() {
        const modes = ['order', 'random', 'single'];
        const modeNames =
            {'order': '顺序播放', 'random': '随机播放', 'single': '单曲循环'};
        const icons = {'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>`;
        
        // Update tooltip logic
        this.el.mode.setAttribute('data-original-title', newTitle);
        this.el.mode.removeAttribute('title');
        dialog.showTooltip(this.el.mode, newTitle);
        
        this.saveState();
    }

    toggleList(show) {
        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);

        // Show/Hide mobile close button
        if (this.el.listCloseMobile) {
            // Check if mobile
            if (window.innerWidth <= 768) {
                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);
            }
        }
    }

    togglePanel(show) {
        if (show) {
            this.el.panel.classList.add('music-player__panel--open');
            // 添加hash(如果不是由hashchange事件触发的)
            if (!this._isHandlingHashChange && window.location.hash !== '#music-player') {
                window.location.hash = '#music-player';
            }
            // 移动端禁用滚动
            if (window.innerWidth <= 768) {
                document.body.style.overflow = 'hidden';
            }
        } else {
            this.el.panel.classList.remove('music-player__panel--open');
            // 清除hash(如果不是由hashchange事件触发的)
            if (!this._isHandlingHashChange && window.location.hash === '#music-player') {
                window.history.replaceState(null, '', window.location.pathname + window.location.search);
            }
            // 恢复滚动
            document.body.style.overflow = '';
        }
    }

    saveState() {
        const currentTrack = this.playlist[this.currentIndex];
        const state = {
            mode: this.playMode,
            trackId: currentTrack ? currentTrack.id : null,
            time: this.audio.currentTime,
            showLyrics: this.showLyrics,
            lyricsOnBackground: this.lyricsOnBackground
        };
        localStorage.setItem('dpz_music_player_state', JSON.stringify(state));
    }

    restoreState() {
        try {
            const saved = localStorage.getItem('dpz_music_player_state');
            if (saved) {
                const state = JSON.parse(saved);

                // Restore mode
                if (state.mode && ['order', 'random', 'single'].includes(state.mode)) {
                    this.playMode = state.mode;
                    const icons = {'order': 'fa-sort-amount-asc', 'random': 'fa-random', 'single': 'fa-retweet'};
                    const modeNames = {'order': '顺序播放', 'random': '随机播放', 'single': '单曲循环'};

                    this.el.mode.innerHTML = `<i class="fa ${icons[this.playMode]}"></i>`;
                    this.el.mode.setAttribute('title', modeNames[this.playMode]);
                    // Update data-original-title if it exists (for tooltip consistency)
                    if (this.el.mode.hasAttribute('data-original-title')) {
                        this.el.mode.setAttribute('data-original-title', modeNames[this.playMode]);
                    }
                }

                // Restore lyrics display state
                if (typeof state.showLyrics === 'boolean') {
                    this.showLyrics = state.showLyrics;
                }
                if (typeof state.lyricsOnBackground === 'boolean') {
                    this.lyricsOnBackground = state.lyricsOnBackground;
                }

                // Restore track by ID
                let index = 0;
                let time = 0;

                if (state.trackId) {
                    const foundIndex = this.playlist.findIndex(t => t.id === state.trackId);
                    if (foundIndex !== -1) {
                        index = foundIndex;
                        // Restore time only if track is found
                        const savedTime = parseFloat(state.time);
                        if (isFinite(savedTime) && savedTime > 0) {
                            time = savedTime;
                        }
                    }
                }
                
                // If trackId not found or not present, index defaults to 0 and time to 0 (as initialized variables)

                // 恢复状态时跳过保存,避免保存错误的进度
                this.loadTrack(index, false, true);

                // 等待音频加载完成后再设置播放时间
                const restoreTime = () => {
                    if (time > 0) {
                        // 确保音频已加载元数据
                        if (this.audio.readyState >= 2) {
                            try {
                                this.audio.currentTime = time;
                                // 重置自动保存时间,避免立即保存恢复的时间
                                this._lastAutoSaveTime = Date.now();
                            } catch (e) {
                                console.warn('Failed to restore time:', e);
                            }
                        } else {
                            // 如果还没加载完成,等待加载完成
                            const onLoadedMetadata = () => {
                                try {
                                    if (time > 0 && this.audio.duration && time < this.audio.duration) {
                                        this.audio.currentTime = time;
                                        // 重置自动保存时间,避免立即保存恢复的时间
                                        this._lastAutoSaveTime = Date.now();
                                    }
                                } catch (e) {
                                    console.warn('Failed to restore time on loadedmetadata:', e);
                                }
                                this.audio.removeEventListener('loadedmetadata', onLoadedMetadata);
                            };
                            this.audio.addEventListener('loadedmetadata', onLoadedMetadata);
                        }
                    }
                };

                // 尝试立即恢复时间,如果音频还没加载则等待
                restoreTime();

                // Restore lyrics display after track is loaded
                this.updateLyricsDisplay();
            } else {
                this.loadTrack(0, false);
            }
        } catch (e) {
            console.error('Failed to restore music player state', e);
            this.loadTrack(0, false);
        }
    }

    formatTime(seconds) {
        if (!isFinite(seconds)) return '0:00';
        const m = Math.floor(seconds / 60);
        const s = Math.floor(seconds % 60);
        return `${m}:${s < 10 ? '0' : ''}${s}`;
    }

    parseLyrics(lrcString) {
        this.lyrics = [];
        this.el.lyricsInner.innerHTML = '';
        this.el.bgLyricsInner.innerHTML = ''; // Reset background lyrics too
        this.el.bgLyricsInner.style.transform = 'translateY(0)'; // Reset scroll

        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 ms = match[3].length === 3 ? parseInt(match[3]) : parseInt(match[3]) * 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;

        // 更新背景歌词(显示全部,通过CSS transform滚动)
        this.el.bgLyricsInner.innerHTML = lyricsHtml;

        // 更新显示状态
        this.updateLyricsDisplay();
    }

    updateLyricsDisplay() {
        // 更新面板内歌词显示
        this.el.lyricsContainer.style.display = (this.showLyrics && !this.lyricsOnBackground) ? 'block' : 'none';
        this.el.cover.style.display = (this.showLyrics && !this.lyricsOnBackground) ? '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);
        }
    }

    syncLyrics(time) {
        if ((!this.showLyrics && !this.lyricsOnBackground) || !this.lyrics.length) 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) {
            this.currentLyricIndex = activeIdx;

            // 同步面板内歌词
            if (this.showLyrics && !this.lyricsOnBackground) {
                const lines = this.el.lyricsInner.children;
                for (let line of lines) line.classList.remove('music-player__lyric-line--current');

                if (activeIdx >= 0 && lines[activeIdx]) {
                    const activeLine = lines[activeIdx];
                    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;
                
                // Clear active classes
                for (let line of lines) {
                    line.classList.remove('music-player__lyric-line--current');
                }
                
                if (activeIdx >= 0 && lines[activeIdx]) {
                    const activeLine = lines[activeIdx];
                    activeLine.classList.add('music-player__lyric-line--current');
                    
                    // Calculate transform to align active line to top
                    // We want activeLine to be at the top of the container
                    const offset = activeLine.offsetTop;
                    
                    container.style.transform = `translateY(-${offset}px)`;
                }
            }
        }
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

下面是对这段音乐播放器代码的逐步说明与主要功能点的总结(中文):

总体概述

  • 这是一个封装好的单例式前端音乐播放器组件(类名 MusicPlayer),使用 HTMLAudioElement(new Audio())播放音频,并负责 UI 同步、播放列表、歌词显示、播放模式、进度保存与恢复、以及与浏览器媒体会话 API 的集成。
  • 通过把实例保存到 window.__music_player_instance,实现单例(再次 new 时返回已有实例)。

初始化与元素缓存

  • constructor 中初始化各种状态变量(播放列表、当前索引、播放模式、歌词相关、UI 显示状态等),并调用 init()。
  • init(): 初始化 DOM 元素(initElements)、绑定事件(bindEvents)、从后端加载推荐音乐(fetchMusic),并监听 URL hash(#music-player)以控制面板打开/关闭,支持浏览器前进后退控制面板状态。
  • initElements(): 缓存常用 DOM 节点(迷你播放器、面板、控制按钮、进度、歌词容器等),并计算环形进度条周长(circumference),根据屏幕宽度决定是否默认显示列表。

事件绑定(bindEvents)

  • 迷你播放器:单击切换播放/暂停(通过延迟 300ms 判断单击),双击或触摸长按打开面板(touch 长按 600ms)。
  • Panel 控制:关闭按钮、遮罩点击(移动端可关闭),列表开关,歌词显示模式切换(三态:关闭 -> 面板内 -> 页面背景 -> 关闭)。
  • 播放控制:播放/暂停、上一首/下一首、播放模式循环切换(顺序/随机/单曲),以及进度条 seek(input 临时显示,change 时设置 audio.currentTime)。
  • audio 事件监听:timeupdate、ended、durationchange、play、pause、error 等,用于 UI 更新、歌词同步、自动保存进度等。
  • 支持 navigator.mediaSession:设置 play/pause/next/previous 操作,以及在 loadTrack 时设置媒体元数据(title/artist/artwork)和在 updatePlayState 设置 playbackState。

获取与渲染播放列表(fetchMusic、renderList)

  • fetchMusic(): 使用 fetch('/Music/Recommend/v2') 获取播放列表 JSON,失败时显示提示(dialog.toast),成功后渲染列表并恢复上次状态 restoreState。
  • renderList(): 把播放列表渲染为 DOM 列表项,并为每项绑定点击事件以加载对应曲目。

加载曲目与 UI 更新(loadTrack)

  • loadTrack(index, autoPlay = true, skipSave = false)
    • 设置 this.currentIndex、audio.src、更新标题/歌手/封面(面板和迷你封面)。
    • 在列表中高亮当前项并滚动可见。
    • 解析歌词(parseLyrics),重置当前歌词索引。
    • 如果 autoPlay 为 true,调用 play() 启动播放;否则更新播放状态为暂停。
    • 更新 MediaSession metadata。
    • 除非 skipSave 为 true,会调用 saveState 保存当前状态。

播放控制与播放模式

  • play()/pause()/togglePlay():对 audio.play()/audio.pause() 的封装;play() 捕获 play 返回的 Promise 的异常(浏览器策略可能阻止自动播放)。
  • updatePlayState(isPlaying):更新按钮图标、迷你状态图标、封面旋转样式、mediaSession playbackState。
  • skip(direction):根据 playMode(order、random、single)决定下一首或上一首;random 会随机选择不等于当前的索引(若列表长度 > 1)。
  • onEnded(): 单曲循环模式会把 currentTime 设为 0 并重播,否则跳到下一首。

进度与自动保存

  • onTimeUpdate(): 更新面板进度条、当前时间文本,更新迷你圆环进度(通过 strokeDashoffset),并调用 syncLyrics 同步歌词。
  • 每 5 秒自动保存一次播放进度(saveState),用 _lastAutoSaveTime 限制频率。

歌词解析与同步(parseLyrics、syncLyrics、updateLyricsDisplay)

  • parseLyrics(lrcString): 使用正则解析 LRC 格式的歌词行 [mm:ss.xx]text,并把解析结果填入 this.lyrics(每项包含 time 和 text),然后把歌词渲染到面板内(el.lyricsInner)和背景歌词容器(el.bgLyricsInner)。
  • updateLyricsDisplay(): 根据 this.showLyrics 与 this.lyricsOnBackground 控制面板内歌词与背景歌词的显示与隐藏,并在必要时立即同步当前时间对应的歌词。
  • syncLyrics(time): 根据当前时间计算应高亮的歌词索引,对面板和背景分别处理:
    • 面板内:为当前行添加当前样式并滚动到可见(scrollIntoView)。
    • 背景:给当前行加样式,并通过 transform translateY(-offset) 把该行移动到容器顶部(实现背景歌词滚动效果)。

状态持久化(saveState / restoreState)

  • saveState(): 将播放模式、当前曲目 ID、当前时间、歌词显示状态保存到 localStorage(key dpz_music_player_state)。
  • restoreState(): 从 localStorage 读取并恢复模式、歌词显示状态、根据保存的 trackId 在 playlist 中找到对应索引并 loadTrack(skipSave),然后尽可能恢复保存的播放时间(考虑 readyState / loadedmetadata)。若没有保存则加载第一首。

UI 面板与列表控制

  • togglePanel(show):打开或关闭主面板;在打开时设置 hash 为 #music-player(除非由 hashchange 处理),移动端打开时禁止页面滚动;关闭时移除 hash(使用 replaceState 避免触发 history 记录)。
  • 通过监听 window.hashchange,支持浏览器前进后退控制面板打开/关闭,并用 _isHandlingHashChange 防止循环触发。
  • toggleList(show):显示/隐藏播放列表,控制移动端的关闭按钮显示,并滚动到高亮项。

其他细节/注意点

  • 使用 dialog.showTooltip / dialog.toast 与外部 dialog 模块集成(文件顶部 import)。
  • 迷你播放器单击/双击区分通过 setTimeout+clearTimeout 实现(避免将双击误判为单击)。
  • lyrics 正则处理了毫秒为 2 或 3 位的情况,并把两位 ms 扩展为毫秒(*10)。
  • 部分操作在加载期间会考虑 readyState 和 loadedmetadata 以确保设置 currentTime 不会抛错。
  • 假设页面中存在对应 id 的 DOM 元素(HTML 在后端模板 _MusicPlayer.cshtml 中注入)。

总结

  • 这是一个功能完善的前端音乐播放器实现,包含播放/暂停、上一/下一、三种播放模式、播放列表渲染、歌词解析与面板/背景展示、播放进度与环形迷你进度、媒体会话集成、以及本地状态持久化与页面 hash 控制面板的打开关闭。代码在可用性细节(单击/双击、触摸长按、自动保存频率、恢复进度时机)上也做了较多考虑。
评论加载中...