本文最后更新于 2026-01-14,文章内容可能已经过时。

📖 简介

YunDevMusic 是一款基于 PyQt5 开发的现代化音乐播放器,支持播放网易云音乐歌单和本地音视频文件。具有实时歌词同步、自定义歌词样式、无边框窗口设计等特性。

作者: ©ZhiMa 2026 All Rights Reserved

致谢:感谢祈杰的API

✨ 主要功能

• 🎵 网易云音乐歌单播放 - 支持通过歌单ID加载和播放网易云音乐歌单

• 📁 本地文件播放 - 支持播放本地音频(mp3、wav、flac)和视频(mp4、mkv、avi、webm)文件

• 📝 实时歌词同步 - 自动加载并实时同步显示歌词

• 🎨 自定义歌词样式 - 可自定义歌词字体、颜色

• 🎬 视频播放支持 - 支持播放视频文件,自动切换视频显示模式

• 💾 设置自动保存 - 所有个性化设置自动保存到本地

• 🎯 无广告、轻量化 - 简洁的界面设计,无任何广告

🚀 安装与运行

环境要求

• Python 3.6 或更高版本

• PyQt5

• requests 库

安装依赖

pip install PyQt5 requests

运行程序

python YunDevMusic.py

恢复出厂设置

如果需要重置所有设置和日志,可以使用以下命令:

python YunDevMusic.py --restore

📚 使用指南

首次启动

1. 首次运行时会显示隐私协议对话框,请仔细阅读后点击"同意"继续使用

2. 随后会显示欢迎界面,程序会自动加载默认歌单「云端热歌」

3. 加载完成后自动进入主界面

基本操作

播放控制

• 播放/暂停: 点击中央的播放按钮(▶/⏸)

• 上一首: 点击 ⏮ 按钮

• 下一首: 点击 ⏭ 按钮

• 进度控制: 拖动进度条可跳转到指定位置

• 音量调节: 使用右侧音量滑块调节音量

播放歌单

• 双击歌单项: 双击左侧歌单列表中的任意歌曲即可播放

• 自动播放: 加载歌单后会自动播放第一首歌曲

• 自动切换: 当前歌曲播放完成后会自动播放下一首

添加本地文件

1. 点击控制栏右侧的 📁 按钮

2. 在文件选择对话框中选择要添加的音频或视频文件

3. 支持多选,选中的文件会添加到当前播放位置之后

4. 添加后会自动播放第一个选中的文件

支持的格式:

• 音频: .mp3, .wav, .flac

• 视频: .mp4, .mkv, .avi, .webm

本地文件歌词

对于本地音频文件,程序会自动查找同名的 .lrc 歌词文件:

• 歌词文件应与音频文件在同一目录

• 文件名相同,仅扩展名不同(如:song.mp3 对应 song.lrc)

• 支持 UTF-8 和 GBK 编码

⚙️ 设置说明

点击标题栏右侧的 ⚙ 按钮打开设置对话框。

歌单ID设置

• 默认歌单: 留空表示使用默认歌单「云端热歌」

• 自定义歌单: 输入网易云音乐歌单ID可切换到指定歌单

◦ 如何获取歌单ID:在网易云音乐网页版或客户端中,打开歌单页面,URL中的数字即为歌单ID

◦ 例如:https://Example.com/#/user/home?id=123456789 中的 123456789 就是歌单ID

歌词设置

• 选择歌词字体: 点击"选择歌词字体"按钮,可自定义歌词显示的字体和大小

• 选择歌词颜色: 点击"选择歌词颜色"按钮,可自定义歌词显示的颜色

• 逐字同步: 勾选"逐字同步"选项(需要歌词支持逐字时间轴)

日志查看

设置对话框中包含日志查看区域,显示最近的应用运行日志,可用于故障排查。

恢复出厂设置

点击"恢复出厂"按钮可以:

• 删除所有保存的设置

• 删除所有日志文件

• 重置为默认配置

• 重新加载默认歌单

📁 文件结构

music/

├── YunDevMusic.py # 主程序文件

├── YunDevMusic_data/ # 数据目录(自动创建)

│ ├── settings.json # 设置文件(自动保存)

│ └── app.log # 日志文件(自动生成)

└── README.md # 本使用文档

数据存储说明

• 设置文件 (settings.json): 保存歌单ID、音量、歌词字体/颜色等个性化设置

• 日志文件 (app.log): 记录程序运行日志,用于故障排查

• 所有数据仅存储在本地,不会上传到任何服务器

🔧 常见问题

Q: 无法播放某些歌曲?

A: 可能的原因:

• 网络连接问题,检查网络连接

• 歌曲链接失效,尝试刷新歌单或切换到其他歌单

• 播放器不支持该格式,检查文件格式是否在支持列表中

Q: 歌词不显示?

A: 可能的原因:

• 该歌曲没有歌词(纯音乐)

• 歌词加载失败(网络问题或API限制)

• 本地文件缺少对应的 .lrc 歌词文件

Q: 如何切换歌单?

A:

1. 点击标题栏的 ⚙ 按钮打开设置

2. 在"歌单ID"输入框中输入新的歌单ID

3. 点击"确定",程序会自动重新加载新歌单

Q: 窗口无法拖动?

A: 请点击标题栏的空白区域(非按钮区域)进行拖动。

Q: 如何重置所有设置?

A: 有两种方式:

1. 在设置对话框中点击"恢复出厂"按钮

2. 使用命令行:python YunDevMusic.py --restore

🔒 隐私说明

• ✅ 本应用不会收集、上传任何用户隐私信息

• ✅ 所有设置和日志仅存储在本地设备

• ✅ 网络请求仅用于获取公开的歌单和音频链接,无用户标识信息

• ✅ 可通过"恢复出厂设置"或卸载应用完全清除所有本地数据

详细隐私协议请参考程序启动时显示的隐私协议对话框。

📝 更新日志

v0.2.1

• 支持网易云音乐歌单播放

• 支持本地音视频文件播放

• 实时歌词同步显示

• 自定义歌词样式(字体、颜色)

• 无边框窗口设计

• 设置自动保存

• 欢迎界面和隐私协议

v0.2.2

• 新增模糊查询

• 优化代码提高了稳定性

• 修复了已知bug

💡 使用技巧

1. 快速切换歌曲: 双击歌单列表中的任意歌曲即可立即播放

2. 批量添加本地文件: 在文件选择对话框中可按住 Ctrl 或 Shift 键多选文件

3. 自定义歌词样式: 在设置中调整字体和颜色,让歌词显示更符合个人喜好

4. 查看运行日志: 在设置对话框中可查看最近的运行日志,帮助排查问题

5. 自动播放: 加载歌单后会自动播放第一首,无需手动操作

技术支持

如遇到问题,可以:

1. 查看设置对话框中的日志信息

2. 检查 YunDevMusic_data/app.log 日志文件

3. 尝试恢复出厂设置后重新配置

GitHub查看

代码
import sys
import os
import requests
import json
import re
import platform
import ctypes
from datetime import datetime
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, 
                             QHBoxLayout, QPushButton, QLabel, QSlider, QListWidget, 
                             QFileDialog, QStackedWidget, QColorDialog, QMessageBox,
                             QFrame, QGraphicsBlurEffect, QDialog, QLineEdit, 
                             QTextEdit, QCheckBox, QFontDialog, QProgressBar)
from PyQt5.QtCore import Qt, QUrl, QTimer, QThread, pyqtSignal, QPoint, QEvent
from PyQt5.QtGui import QColor, QFont, QPixmap, QImage, QDesktopServices
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
from PyQt5.QtMultimediaWidgets import QVideoWidget

# --- Constants & Config ---
DEFAULT_PLAYLIST_ID = "17666671541"
API_BASE = "https://api.qijieya.cn/meting/"
REQUEST_HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116 Safari/537.36',
    'Referer': 'https://music.163.com',
}
REQUEST_TIMEOUT = 10
# Data folder for logs and settings (created in script directory)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, 'YunDevMusic_data')
SETTINGS_FILE = os.path.join(DATA_DIR, 'settings.json')
LOG_FILE = os.path.join(DATA_DIR, 'app.log')

# Default persistent settings
DEFAULT_SETTINGS = {
    'playlist_id': DEFAULT_PLAYLIST_ID,
    'per_char_sync': False,
    'volume': 70,
    'lyrics_font': {'family': 'Arial', 'size': 12},
    'lyrics_color': [255, 255, 255, 200],
    'privacy_accepted': False
}

def hide_console_on_windows():
    try:
        if platform.system().lower() == 'windows':
            hwnd = ctypes.windll.kernel32.GetConsoleWindow()
            if hwnd:
                SW_HIDE = 0
                ctypes.windll.user32.ShowWindow(hwnd, SW_HIDE)
    except Exception:
        pass

def ensure_data_dir():
    try:
        os.makedirs(DATA_DIR, exist_ok=True)
    except Exception:
        pass

def load_settings():
    try:
        if os.path.exists(SETTINGS_FILE):
            with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
    except Exception:
        pass
    return DEFAULT_SETTINGS.copy()

def save_settings(settings: dict):
    try:
        ensure_data_dir()
        with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
            json.dump(settings, f, ensure_ascii=False, indent=2)
    except Exception:
        pass

def append_log_line(line: str):
    try:
        ensure_data_dir()
        ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        with open(LOG_FILE, 'a', encoding='utf-8') as f:
            f.write(f"[{ts}] {line}\n")
    except Exception:
        pass

def read_log_lines(limit=1000):
    try:
        if os.path.exists(LOG_FILE):
            with open(LOG_FILE, 'r', encoding='utf-8') as f:
                lines = f.read().splitlines()
                return lines[-limit:]
    except Exception:
        pass
    return []

def restore_factory_files():
    try:
        # remove settings and log files if exist
        if os.path.exists(SETTINGS_FILE):
            os.remove(SETTINGS_FILE)
        if os.path.exists(LOG_FILE):
            os.remove(LOG_FILE)
    except Exception:
        pass

# --- Workers ---
class ApiWorker(QThread):
    finished = pyqtSignal(list, str)
    error = pyqtSignal(str)
    progress = pyqtSignal(int, str)
    
    def __init__(self, api_url, server="netease", playlist_id="", parent=None):
        super().__init__(parent)
        self.api_url = api_url
        self.server = server
        self.playlist_id = playlist_id
        
    def run(self):
        tracks = []
        playlist_name = "云端热歌"
        try:
            # 1. Fetch Tracks
            self.progress.emit(10, "正在连接服务器...")
            resp = requests.get(self.api_url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT)
            
            self.progress.emit(40, "正在获取歌单数据...")
            data = resp.json()
            if not isinstance(data, list):
                data = [data]
            tracks = data
            self.progress.emit(70, f"已解析 {len(tracks)} 首歌曲...")
            
            # 2. Fetch Playlist Name (Only for Netease)
            if self.server == "netease" and self.playlist_id:
                try:
                    self.progress.emit(80, "正在获取歌单详情...")
                    info_url = f"http://music.163.com/api/playlist/detail?id={self.playlist_id}"
                    info_resp = requests.get(info_url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT)
                    info_data = info_resp.json()
                    if 'result' in info_data and 'name' in info_data['result']:
                        playlist_name = info_data['result']['name']
                except Exception as e:
                    pass
            
            self.progress.emit(90, "即将完成...")
                    
        except Exception as e:
            self.error.emit(f"API 请求失败: {e}")
            
        self.finished.emit(tracks, playlist_name)

class LyricsWorker(QThread):
    finished = pyqtSignal(str, str) # track_id, lyrics_text
    error = pyqtSignal(str)
    
    def __init__(self, track, parent=None):
        super().__init__(parent)
        self.track = track
        
    def run(self):
        lrc_text = ""
        try:
            # 1. Check if lyrics are already embedded in track data
            if self.track.get('lrc') and not self.track['lrc'].startswith('http') and len(self.track['lrc']) > 10:
                lrc_text = self.track['lrc']
            # 2. Check if lyrics URL is provided
            elif self.track.get('lrc') and self.track['lrc'].startswith('http'):
                resp = requests.get(self.track['lrc'], headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT)
                lrc_text = resp.text
            # 3. For local files, try to find .lrc file with same name
            elif self.track.get('is_local') and self.track.get('path'):
                lrc_path = os.path.splitext(self.track['path'])[0] + '.lrc'
                if os.path.exists(lrc_path):
                    try:
                        with open(lrc_path, 'r', encoding='utf-8') as f:
                            lrc_text = f.read()
                    except Exception:
                        # If reading fails, try other encodings
                        try:
                            with open(lrc_path, 'r', encoding='gbk') as f:
                                lrc_text = f.read()
                        except Exception:
                            pass  # Skip silently if can't read .lrc file
                # If no .lrc file found, skip silently (don't try API)
            # 4. For online tracks, try API
            elif self.track.get('id'):
                url = f"{API_BASE}?server=netease&type=lrc&id={self.track.get('id')}"
                resp = requests.get(url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT)
                resp.raise_for_status()
                data = resp.json()
                lrc_text = data.get('lyric', '')
        except Exception as e:
            # Silently skip lyrics loading errors - don't emit error signal
            # This allows audio to play even if lyrics fail to load
            lrc_text = ""
            
        # emit a reliable identifier: prefer id, then path, then name
        ident = self.track.get('id') or self.track.get('path') or self.track.get('name')
        self.finished.emit(str(ident), lrc_text)

class ImageWorker(QThread):
    finished = pyqtSignal(QImage)
    error = pyqtSignal(str)
    
    def __init__(self, url, parent=None):
        super().__init__(parent)
        self.url = url
        
    def run(self):
        try:
            if self.url:
                resp = requests.get(self.url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT)
                data = resp.content
                image = QImage()
                image.loadFromData(data)
                self.finished.emit(image)
            else:
                self.finished.emit(QImage())
        except:
            self.error.emit("封面加载失败")
            self.finished.emit(QImage())

# --- Custom Title Bar ---
class TitleBar(QWidget):
    def __init__(self, parent):
        super().__init__(parent)
        self.setFixedHeight(40)
        layout = QHBoxLayout(self)
        layout.setContentsMargins(10, 0, 10, 0)
        
        self.title_label = QLabel("Music Player")
        self.title_label.setStyleSheet("color: white; font-weight: bold;")
        
        # Settings button left of minimize
        self.btn_settings = QPushButton("⚙")
        self.btn_min = QPushButton("-")
        self.btn_close = QPushButton("✕")
        
        for btn in [self.btn_settings, self.btn_min, self.btn_close]:
            btn.setFixedSize(30, 30)
            btn.setStyleSheet("""
                QPushButton { background: transparent; color: white; border: none; font-size: 14px; }
                QPushButton:hover { background: rgba(255,255,255,0.2); border-radius: 15px; }
            """)
            
        self.btn_close.setStyleSheet(self.btn_close.styleSheet().replace("hover {", "hover { background: #ff4444;"))

        layout.addWidget(self.title_label)
        layout.addStretch()
        layout.addWidget(self.btn_settings)
        layout.addWidget(self.btn_min)
        layout.addWidget(self.btn_close)
        
        # connect safely to top-level window methods
        self.btn_settings.clicked.connect(lambda: getattr(self.window(), 'open_settings', lambda: None)())
        self.btn_min.clicked.connect(parent.showMinimized)
        self.btn_close.clicked.connect(parent.close)

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            try:
                w = self.window()
                if hasattr(w, 'window_start_move'):
                    w.window_start_move(event.globalPos())
            except Exception:
                pass

    def mouseMoveEvent(self, event):
        if event.buttons() & Qt.LeftButton:
            try:
                w = self.window()
                if hasattr(w, 'window_do_move'):
                    w.window_do_move(event.globalPos())
            except Exception:
                pass

    def mouseReleaseEvent(self, event):
        return super().mouseReleaseEvent(event)


class SettingsDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("设置")
        self.resize(500, 400)
        self.parent = parent
        # 添加帮助按钮(问号按钮)
        self.setWindowFlags(self.windowFlags() | Qt.WindowContextHelpButtonHint)

        layout = QVBoxLayout(self)

        # Playlist ID
        self.le_playlist = QLineEdit()
        self.le_playlist.setPlaceholderText("歌单ID(留空使用默认)")
        layout.addWidget(QLabel("歌单ID(留空表示使用默认):"))
        layout.addWidget(self.le_playlist)

        # Lyrics options
        self.btn_font = QPushButton("选择歌词字体")
        self.btn_color = QPushButton("选择歌词颜色")
        self.cb_perchar = QCheckBox("逐字同步(开启后可能需要歌词支持)")
        h = QHBoxLayout()
        h.addWidget(self.btn_font)
        h.addWidget(self.btn_color)
        layout.addLayout(h)
        layout.addWidget(self.cb_perchar)

        # Log viewer
        layout.addWidget(QLabel("日志:"))
        self.tx_log = QTextEdit()
        self.tx_log.setReadOnly(True)
        layout.addWidget(self.tx_log)

        # Buttons
        b_h = QHBoxLayout()
        self.btn_ok = QPushButton("确定")
        self.btn_cancel = QPushButton("取消")
        self.btn_restore = QPushButton("恢复出厂")
        b_h.addStretch()
        b_h.addWidget(self.btn_restore)
        b_h.addWidget(self.btn_ok)
        b_h.addWidget(self.btn_cancel)
        layout.addLayout(b_h)

        self.btn_cancel.clicked.connect(self.reject)
        self.btn_ok.clicked.connect(self.accept)
        self.btn_font.clicked.connect(self.choose_font)
        self.btn_color.clicked.connect(self.choose_color)
        self.btn_restore.clicked.connect(self.do_restore)

    def choose_font(self):
        f, ok = QFontDialog.getFont(self.parent.lyrics_font, self)
        if ok:
            self.parent.lyrics_font = f

    def choose_color(self):
        c = QColorDialog.getColor(self.parent.lyrics_color, self)
        if c.isValid():
            self.parent.lyrics_color = c

    def exec_(self):
        # populate fields
        if getattr(self.parent, 'current_playlist_id', DEFAULT_PLAYLIST_ID) == DEFAULT_PLAYLIST_ID:
            self.le_playlist.setText("")
        else:
            self.le_playlist.setText(str(self.parent.current_playlist_id))
        self.cb_perchar.setChecked(self.parent.per_char_sync)
        # logs
        self.tx_log.setPlainText('\n'.join(self.parent.log_lines[-200:]))
        return super().exec_()

    def do_restore(self):
        try:
            if hasattr(self.parent, 'restore_factory'):
                self.parent.restore_factory()
                # refresh dialog's contents
                self.tx_log.setPlainText('\n'.join(self.parent.log_lines[-200:]))
        except Exception:
            QMessageBox.warning(self, "失败", "恢复出厂设置失败")

    def event(self, event):
        # 处理帮助按钮点击事件
        if event.type() == QEvent.WhatsThis:
            self.show_help()
            return True
        return super().event(event)

    def show_help(self):
        """打开 GitHub 仓库页面"""
        # GitHub 仓库地址(请替换为实际的仓库地址)
        github_url = "https://github.com/halosb/YunDevMusic"
        # 如果 README.md 中有 GitHub 链接,可以尝试读取
        try:
            readme_path = os.path.join(BASE_DIR, 'README.md')
            if os.path.exists(readme_path):
                with open(readme_path, 'r', encoding='utf-8') as f:
                    content = f.read()
                    # 尝试从 README.md 中提取 GitHub 链接
                    github_match = re.search(r'https?://github\.com/[\w\-]+/[\w\-]+', content)
                    if github_match:
                        github_url = github_match.group(0)
        except Exception:
            pass
        
        # 打开浏览器跳转到 GitHub
        QDesktopServices.openUrl(QUrl(github_url))


class PrivacyDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("隐私协议")
        self.resize(640, 480)
        layout = QVBoxLayout(self)

        text = '''YunDevMusic 隐私协议
====================
1. 数据收集说明:
   本应用不会收集、上传任何用户隐私信息,包括但不限于手机号、身份证号、地理位置、浏览记录等。

2. 本地存储说明:
   - 仅在本地目录「YunDevMusic_data」下存储以下基础数据:
     ✅ 歌单ID、音量设置、歌词字体/颜色等个性化配置;
     ✅ 应用运行日志(仅本地保存,用于故障排查,无用户标识信息)。
   - 所有数据均存储在您的设备本地,不会上传至任何服务器。

3. 权限说明:
   - 仅请求「文件读取权限」(用于播放本地音视频文件),无其他敏感权限请求;
   - 网络请求仅用于获取网易云音乐公开歌单、音频链接、歌词(无用户标识)。

4. 数据删除:
   - 您可通过「设置」中的重置功能删除所有本地存储的配置和日志;
   - 卸载应用可完全清除本地存储的所有数据。

您点击「同意」即表示您知晓并同意本协议,点击「拒绝」将退出应用。
'''

        te = QTextEdit()
        te.setReadOnly(True)
        te.setPlainText(text)
        layout.addWidget(te)

        h = QHBoxLayout()
        btn_decline = QPushButton("拒绝")
        btn_accept = QPushButton("同意")
        h.addStretch()
        h.addWidget(btn_decline)
        h.addWidget(btn_accept)
        layout.addLayout(h)

        btn_decline.clicked.connect(self.reject)
        btn_accept.clicked.connect(self._do_accept)

    def _do_accept(self):
        try:
            s = load_settings()
            s['privacy_accepted'] = True
            save_settings(s)
        except Exception:
            pass
        self.accept()


class WelcomeDialog(QDialog):
    def __init__(self, player: 'MusicPlayer', parent=None):
        super().__init__(parent)
        self.player = player
        self.setWindowTitle("欢迎")
        self.resize(700, 420)
        layout = QVBoxLayout(self)

        title = QLabel("YunDevMusic 音乐播放器 Pro")
        title.setStyleSheet('font-size:20px; font-weight:bold;')
        subtitle = QLabel("v0.2.1")
        subtitle.setStyleSheet('color:gray;')
        welcome = QLabel("欢迎使用 YunDevMusic 🎵")
        core = QLabel("网易云歌单/本地音视频 | 歌词实时/逐字同步 | 自定义歌词样式")
        highlights = QLabel("无广告 · 轻量化 · 本地设置自动保存")
        tips = QLabel("双击歌单播放 | ⚙ 自定义歌词 | 📁 添加本地文件")
        for w in (title, subtitle, welcome, core, highlights, tips):
            w.setAlignment(Qt.AlignCenter)
            layout.addWidget(w)

        self.progress = QProgressBar()
        self.progress.setRange(0, 100)
        self.progress.setValue(0)
        layout.addWidget(self.progress)

        self.status_label = QLabel('正在加载默认歌单「云端热歌」...(进度:0%)')
        self.status_label.setAlignment(Qt.AlignCenter)
        layout.addWidget(self.status_label)

        footer = QLabel('©ZhiMa 2026 All Rights Reserved.  仅本地存储设置,无隐私数据收集')
        footer.setAlignment(Qt.AlignCenter)
        footer.setStyleSheet('color: gray; font-size: 11px;')
        layout.addWidget(footer)


        # connect to player signals
        self.player.playlist_loading_progress.connect(self.on_progress)
        self.player.playlist_loading_finished.connect(self.on_finished)

        # ensure loading starts after the welcome dialog's event loop begins
        try:
            QTimer.singleShot(50, lambda: getattr(self.player, 'start_loading', lambda: None)())
        except Exception:
            pass

    def on_progress(self, value:int, message=""):
        self.progress.setValue(value)
        if message:
            self.status_label.setText(f'{message}(进度:{value}%)')
        else:
            self.status_label.setText(f'正在加载默认歌单「云端热歌」...(进度:{value}%)')

    def on_finished(self):
        self.progress.setValue(100)
        self.status_label.setText('加载完成(进度:100%)')
        # auto-advance to player when loading finished
        QTimer.singleShot(200, self.accept)

# --- Main Player Class ---
class MusicPlayer(QMainWindow):
    playlist_loading_started = pyqtSignal()
    playlist_loading_progress = pyqtSignal(int, str)
    playlist_loading_finished = pyqtSignal()
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Music Player Pro")
        # ensure data directory and load persisted settings/logs
        ensure_data_dir()
        settings = load_settings()
        # apply persistent settings to initial state
        self.current_playlist_id = settings.get('playlist_id', DEFAULT_PLAYLIST_ID)
        self.per_char_sync = settings.get('per_char_sync', False)
        self._initial_volume = settings.get('volume', 70)
        lf = settings.get('lyrics_font', {'family': 'Arial', 'size': 12})
        self.lyrics_font = QFont(lf.get('family', 'Arial'), lf.get('size', 12))
        lc = settings.get('lyrics_color', [255, 255, 255, 200])
        self.lyrics_color = QColor(*lc)
        # load existing logs
        self.log_lines = read_log_lines(1000)
        self.resize(1000, 700)
        
        # Frameless & Translucent
        self.setWindowFlags(Qt.FramelessWindowHint | Qt.Window)
        self.setAttribute(Qt.WA_TranslucentBackground)
        
        # State
        self.playlist = [] 
        self.current_index = -1
        self.lyrics_data = [] 
        self.is_playing = False
        self.accent_color = "#1db954" 
        # Caches and logs
        self.lyrics_cache = {}
        self.cover_cache = {}

        # Lyrics display options
        # `self.lyrics_font` and `self.lyrics_color` are set from loaded settings above
        
        # Window Drag State
        self.drag_start_position = QPoint()
        self.window_start_position = QPoint()
        
        # Multimedia (PyQt5)
        self.player = QMediaPlayer(self)
        
        # Initialize UI and connections
        self.init_ui()
        self.setup_connections()
        
        # Set Video Output ONCE (to avoid crashes when switching)
        self.player.setVideoOutput(self.video_widget)

    def append_log(self, text: str):
        try:
            # keep in-memory short history (recent entries)
            self.log_lines.append(text)
            append_log_line(text)
        except Exception:
            pass

    def restore_factory(self):
        # interactive restore triggered from UI
        resp = QMessageBox.question(self, "恢复出厂设置", "确定要恢复出厂设置?这将删除设置与日志并重置为默认。", QMessageBox.Yes | QMessageBox.No)
        if resp != QMessageBox.Yes:
            return
        try:
            restore_factory_files()
            # apply defaults in memory and save
            save_settings(DEFAULT_SETTINGS.copy())
            self.per_char_sync = DEFAULT_SETTINGS.get('per_char_sync', False)
            self.current_playlist_id = DEFAULT_SETTINGS.get('playlist_id', DEFAULT_PLAYLIST_ID)
            lf = DEFAULT_SETTINGS.get('lyrics_font', {'family': 'Arial', 'size': 12})
            self.lyrics_font = QFont(lf.get('family', 'Arial'), lf.get('size', 12))
            lc = DEFAULT_SETTINGS.get('lyrics_color', [255,255,255,200])
            self.lyrics_color = QColor(*lc)
            self.log_lines = []
            QMessageBox.information(self, "已恢复", "已恢复出厂设置,正在重新加载默认歌单。")
            self.load_online_playlist()
        except Exception:
            QMessageBox.warning(self, "失败", "恢复出厂设置时发生错误。")

    def start_loading(self):
        # Begin loading playlist (call this after welcome dialog connects to signals)
        try:
            self.load_online_playlist()
        except Exception:
            pass

    def show_warning(self, title, message):
        # log and show a QMessageBox
        entry = f"{title}: {message}"
        try:
            self.append_log(entry)
        except Exception:
            pass
        QMessageBox.warning(self, title, message)

    def handle_worker_error(self, msg):
        self.show_warning("请求错误", msg)

    def window_start_move(self, global_pos):
        self.drag_start_position = global_pos
        self.window_start_position = self.pos()

    def window_do_move(self, global_pos):
        delta = global_pos - self.drag_start_position
        self.move(self.window_start_position + delta)

    def init_ui(self):
        # Main Container (Rounded Corners & Background)
        self.container = QWidget()
        self.container.setObjectName("MainContainer")
        self.setCentralWidget(self.container)
        
        # Background Layer for Blur
        self.bg_label = QLabel(self.container)
        self.bg_label.setScaledContents(True)
        self.bg_label.setStyleSheet("background-color: #222;")
        
        # Blur Effect
        self.blur_effect = QGraphicsBlurEffect()
        self.blur_effect.setBlurRadius(30)
        self.bg_label.setGraphicsEffect(self.blur_effect)
        
        # Overlay for tint
        self.overlay = QWidget(self.container)
        self.overlay.setStyleSheet("background-color: rgba(0,0,0,0.6);")
        
        # Layouts
        self.main_layout = QVBoxLayout(self.container)
        self.main_layout.setContentsMargins(0,0,0,0)
        self.main_layout.setSpacing(0)
        
        # 1. Custom Title Bar
        self.title_bar = TitleBar(self)
        self.main_layout.addWidget(self.title_bar)
        
        # 2. Content Area
        content_widget = QWidget()
        content_layout = QHBoxLayout(content_widget)
        content_layout.setContentsMargins(20, 10, 20, 10)
        
        # Left: Playlist
        self.playlist_widget = QListWidget()
        self.playlist_widget.setFixedWidth(260)
        self.playlist_widget.setFrameShape(QFrame.NoFrame)
        self.playlist_widget.itemDoubleClicked.connect(self.on_playlist_double_click)
        
        # Right: Display
        self.content_stack = QStackedWidget()
        
        # Audio Page
        audio_page = QWidget()
        audio_layout = QVBoxLayout(audio_page)
        
        self.lbl_title = QLabel("Ready to Play")
        self.lbl_title.setAlignment(Qt.AlignCenter)
        self.lbl_title.setWordWrap(True)
        self.lbl_title.setStyleSheet("font-size: 24px; font-weight: bold; color: white; margin-bottom: 5px;")
        
        self.lbl_artist = QLabel("")
        self.lbl_artist.setAlignment(Qt.AlignCenter)
        self.lbl_artist.setStyleSheet("font-size: 16px; color: rgba(255,255,255,0.7); margin-bottom: 20px;")
        
        self.lyrics_list = QListWidget()
        self.lyrics_list.setFocusPolicy(Qt.NoFocus)
        self.lyrics_list.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.lyrics_list.setFrameShape(QFrame.NoFrame)
        self.lyrics_list.setStyleSheet("background: transparent;")
        
        audio_layout.addStretch()
        audio_layout.addWidget(self.lbl_title)
        audio_layout.addWidget(self.lbl_artist)
        audio_layout.addWidget(self.lyrics_list)
        audio_layout.addStretch()
        
        # Video Page
        self.video_widget = QVideoWidget()
        
        self.content_stack.addWidget(audio_page)
        self.content_stack.addWidget(self.video_widget)
        
        content_layout.addWidget(self.playlist_widget)
        content_layout.addWidget(self.content_stack)
        
        self.main_layout.addWidget(content_widget)
        
        # 3. Controls
        controls_bar = QWidget()
        controls_bar.setFixedHeight(90)
        controls_bar.setStyleSheet("background-color: rgba(0,0,0,0.3); border-top: 1px solid rgba(255,255,255,0.1);")
        c_layout = QHBoxLayout(controls_bar)
        c_layout.setContentsMargins(20, 10, 20, 10)
        
        self.btn_prev = QPushButton("⏮")
        self.btn_play = QPushButton("▶")
        self.btn_next = QPushButton("⏭")
        self.btn_open = QPushButton("📁")
        
        for btn in [self.btn_prev, self.btn_next]:
            btn.setFixedSize(40, 40)
            btn.setObjectName("CtrlBtn")
            
        self.btn_play.setFixedSize(50, 50)
        self.btn_play.setObjectName("PlayBtn")
        
        self.btn_prev.clicked.connect(self.play_prev)
        self.btn_play.clicked.connect(self.toggle_play)
        self.btn_next.clicked.connect(self.play_next)
        self.btn_open.clicked.connect(self.open_local_files)
        
        # Progress
        self.lbl_curr = QLabel("00:00")
        self.slider = QSlider(Qt.Horizontal)
        self.slider.sliderMoved.connect(self.set_position)
        self.lbl_total = QLabel("00:00")
        
        for l in [self.lbl_curr, self.lbl_total]:
            l.setStyleSheet("color: #ccc; font-size: 12px;")
            
        # Volume
        self.btn_vol = QPushButton("🔊")
        self.btn_vol.setFlat(True)
        self.btn_vol.setStyleSheet("color: #ccc; border: none;")
        self.slider_vol = QSlider(Qt.Horizontal)
        self.slider_vol.setFixedWidth(80)
        self.slider_vol.setRange(0, 100)
        try:
            self.slider_vol.setValue(int(getattr(self, '_initial_volume', 70)))
        except Exception:
            self.slider_vol.setValue(70)
        self.slider_vol.valueChanged.connect(self.player.setVolume)
        
        c_layout.addWidget(self.btn_prev)
        c_layout.addSpacing(10)
        c_layout.addWidget(self.btn_play)
        c_layout.addSpacing(10)
        c_layout.addWidget(self.btn_next)
        c_layout.addSpacing(20)
        c_layout.addWidget(self.lbl_curr)
        c_layout.addWidget(self.slider)
        c_layout.addWidget(self.lbl_total)
        c_layout.addSpacing(20)
        c_layout.addWidget(self.btn_vol)
        c_layout.addWidget(self.slider_vol)
        c_layout.addSpacing(10)
        c_layout.addWidget(self.btn_open)
        c_layout.addStretch()
        self.lbl_version = QLabel("v0.2.1 ©ZhiMa 2026")
        self.lbl_version.setStyleSheet("color: #aaa;")
        c_layout.addWidget(self.lbl_version)
        
        self.main_layout.addWidget(controls_bar)
        
        self.apply_qss()

    def resizeEvent(self, event):
        self.bg_label.resize(self.size())
        self.overlay.resize(self.size())
        super().resizeEvent(event)

    def apply_qss(self):
        qss = f"""
            #MainContainer {{
                background: transparent;
                border-radius: 15px;
            }}
            QListWidget {{
                background: rgba(0,0,0,0.2);
                border-radius: 10px;
                color: #ddd;
                font-size: 14px;
                padding: 5px;
            }}
            QListWidget::item {{
                height: 30px;
                padding: 5px;
                border-radius: 5px;
            }}
            QListWidget::item:selected {{
                background-color: rgba(255,255,255,0.15);
                color: {self.accent_color};
            }}
            QListWidget::item:hover {{
                background-color: rgba(255,255,255,0.05);
            }}
            
            /* Controls */
            #CtrlBtn {{
                background: transparent;
                color: white;
                border: 1px solid rgba(255,255,255,0.2);
                border-radius: 20px;
                font-size: 16px;
            }}
            #CtrlBtn:hover {{
                background: rgba(255,255,255,0.1);
                border-color: white;
            }}
            #PlayBtn {{
                background: {self.accent_color};
                color: white;
                border-radius: 25px;
                font-size: 20px;
            }}
            #PlayBtn:hover {{
                background: #1ed760;
                transform: scale(1.05);
            }}
            
            /* Sliders */
            QSlider::groove:horizontal {{
                border: 1px solid rgba(255,255,255,0.1);
                height: 4px;
                background: rgba(255,255,255,0.2);
                margin: 2px 0;
                border-radius: 2px;
            }}
            QSlider::handle:horizontal {{
                background: white;
                border: 1px solid #5c5c5c;
                width: 14px;
                height: 14px;
                margin: -6px 0;
                border-radius: 7px;
            }}
            QSlider::sub-page:horizontal {{
                background: {self.accent_color};
                border-radius: 2px;
            }}
        """
        self.setStyleSheet(qss)

    def setup_connections(self):
        self.player.positionChanged.connect(self.on_position_changed)
        self.player.durationChanged.connect(self.on_duration_changed)
        self.player.mediaStatusChanged.connect(self.on_media_status_changed)
        self.player.error.connect(self.on_player_error)

    # --- Logic ---

    def load_online_playlist(self):
        # stop previous worker if running
        if hasattr(self, 'worker') and self.worker.isRunning():
            try:
                self.worker.quit()
                self.worker.wait()
            except Exception:
                pass

        url = f"{API_BASE}?server=netease&type=playlist&id={self.current_playlist_id}"
        if hasattr(self, 'title_bar') and self.title_bar:
            self.title_bar.title_label.setText("Loading...")
        # emit started and a mid-progress value
        try:
            self.playlist_loading_started.emit()
            self.playlist_loading_progress.emit(5, "正在初始化...")
        except Exception:
            pass
        self.worker = ApiWorker(url, server="netease", playlist_id=self.current_playlist_id, parent=self)
        self.worker.finished.connect(self.on_playlist_loaded)
        self.worker.error.connect(self.handle_worker_error)
        self.worker.progress.connect(self.on_worker_progress)
        self.worker.start()

    def on_worker_progress(self, value, msg):
        try:
            self.playlist_loading_progress.emit(value, msg)
        except Exception:
            pass

    def on_playlist_loaded(self, data, playlist_name):
        self.playlist = data
        self.playlist_widget.clear()
        for track in self.playlist:
            name = track.get('name', 'Unknown')
            artist = track.get('artist', 'Unknown')
            self.playlist_widget.addItem(f"{name} - {artist}")
        self.title_bar.title_label.setText(f"{playlist_name}")
        # log playlist load
        try:
            self.append_log(f"Loaded playlist: {playlist_name} ({len(self.playlist)} tracks)")
        except Exception:
            pass
        # if no track was playing before, start playing first track; otherwise keep current playback
        if getattr(self, 'current_index', -1) == -1 and self.playlist:
            self.play_track(0)
        try:
            self.playlist_loading_progress.emit(100, "加载完成")
            self.playlist_loading_finished.emit()
        except Exception:
            pass

    def on_playlist_double_click(self, item):
        idx = self.playlist_widget.row(item)
        self.play_track(idx)

    def open_settings(self):
        dlg = SettingsDialog(self)
        if dlg.exec_():
            new_id = dlg.le_playlist.text().strip()
            if new_id == "":
                new_id = DEFAULT_PLAYLIST_ID

            # apply lyrics display settings immediately
            self.per_char_sync = dlg.cb_perchar.isChecked()
            self.lyrics_font = getattr(self, 'lyrics_font', QFont('Arial', 12))
            self.lyrics_color = getattr(self, 'lyrics_color', QColor(255,255,255,200))

            # Only reload playlist if ID actually changed
            if str(new_id) != str(getattr(self, 'current_playlist_id', DEFAULT_PLAYLIST_ID)):
                self.current_playlist_id = new_id
                try:
                    self.append_log(f"Playlist ID changed -> reloading: {self.current_playlist_id}")
                except Exception:
                    pass
                self.load_online_playlist()
            else:
                # update lyrics UI (no interruption)
                for i in range(self.lyrics_list.count()):
                    it = self.lyrics_list.item(i)
                    it.setFont(self.lyrics_font)
                    it.setForeground(self.lyrics_color)
                try:
                    self.append_log("Settings applied (no reload)")
                except Exception:
                    pass

    def open_local_files(self):
        files, _ = QFileDialog.getOpenFileNames(self, "选择音频或视频文件", os.path.expanduser('~'), "音频/视频 (*.mp3 *.wav *.flac *.mp4 *.mkv *.avi *.webm);;All Files (*)")
        if not files:
            return
        insert_at = self.current_index + 1 if self.current_index >= 0 else len(self.playlist)
        for i, p in enumerate(files):
            item = {'name': os.path.basename(p), 'artist': '本地文件', 'path': p, 'is_local': True}
            self.playlist.insert(insert_at + i, item)
            self.playlist_widget.insertItem(insert_at + i, f"{item['name']} - {item['artist']}")
        # play first selected
        self.play_track(insert_at)

    def play_track(self, index):
        if not self.playlist or index < 0 or index >= len(self.playlist):
            return

        self.current_index = index
        track = self.playlist[index]
        self.playlist_widget.setCurrentRow(index)
        
        # Info
        name = track.get('name', 'Unknown')
        artist = track.get('artist', 'Unknown')
        self.lbl_title.setText(name)
        self.lbl_artist.setText(artist)
        try:
            self.append_log(f"Play: {name} - {artist} (index={self.current_index})")
        except Exception:
            pass
        
        # Background Image
        pic_url = track.get('pic', '')
        if pic_url:
            # use cache when possible
            if pic_url in self.cover_cache:
                self.update_background(self.cover_cache[pic_url])
            else:
                if hasattr(self, 'img_worker') and getattr(self, 'img_worker').isRunning():
                    try:
                        self.img_worker.quit(); self.img_worker.wait()
                    except Exception:
                        pass
                self.img_worker = ImageWorker(pic_url, parent=self)
                self.img_worker.finished.connect(lambda img, url=pic_url: self.handle_image_finished(img, url))
                self.img_worker.error.connect(self.handle_worker_error)
                self.img_worker.start()
        
        # URL
        url = track.get('url')
        is_local = track.get('is_local', False)
        if not url or 'null' in url:
            if track.get('id'):
                try:
                    api_url = f"{API_BASE}?server=netease&type=url&id={track['id']}"
                    resp = requests.get(api_url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT)
                    resp.raise_for_status()
                    data = resp.json()
                    url = data.get('url')
                except Exception as e:
                    self.show_warning("播放失败", f"获取曲目播放链接失败: {e}")
                    url = None
        
        is_video = False
        if is_local:
            # local file path provided in 'path'
            path = track.get('path')
            if path and os.path.exists(path):
                qurl = QUrl.fromLocalFile(path)
                media = QMediaContent(qurl)
                is_video = path.lower().endswith(('.mp4', '.mkv', '.avi', '.webm'))
            else:
                self.show_warning("播放失败", f"本地文件不存在: {track.get('name')}")
                QTimer.singleShot(100, self.play_next)
                return
        else:
            if not url or 'null' in url:
                self.show_warning("播放失败", f"曲目 {track.get('name')} 播放链接无效,自动切换下一曲")
                QTimer.singleShot(100, self.play_next)
                return
            qurl = QUrl(url)
            media = QMediaContent(qurl)
            # Video Check for online files
            if isinstance(url, str) and url.lower().endswith(('.mp4', '.mkv', '.avi', '.webm')):
                is_video = True
        
        if is_video:
            self.content_stack.setCurrentWidget(self.video_widget)
        else:
            self.content_stack.setCurrentWidget(self.content_stack.widget(0))
            
        self.player.setMedia(media)
        self.player.play()
        self.is_playing = True
        self.btn_play.setText("⏸")
        
        # Lyrics
        if not is_video:
            self.lyrics_list.clear()
            self.lyrics_data = []
            # try cache first
            lyric_key = str(track.get('id') or track.get('path') or track.get('name'))
            if lyric_key in self.lyrics_cache:
                self.on_lyrics_loaded(lyric_key, self.lyrics_cache[lyric_key])
            else:
                if hasattr(self, 'lyric_worker') and getattr(self, 'lyric_worker').isRunning():
                    try:
                        self.lyric_worker.quit(); self.lyric_worker.wait()
                    except Exception:
                        pass
                self.lyric_worker = LyricsWorker(track, parent=self)
                self.lyric_worker.finished.connect(self.on_lyrics_loaded)
                # Don't show error for lyrics loading failures - just skip silently
                self.lyric_worker.error.connect(lambda msg: self.append_log(f"歌词加载跳过: {msg}"))
                self.lyric_worker.start()

    def handle_image_finished(self, image, url):
        if not image.isNull():
            # cache and update
            self.cover_cache[url] = image
            self.update_background(image)
        else:
            # fallback placeholder
            try:
                self.bg_label.setPixmap(QPixmap("default_cover.png"))
            except Exception:
                self.bg_label.setStyleSheet("background-color: #333;")

    def update_background(self, image):
        if not image.isNull():
            pixmap = QPixmap.fromImage(image)
            self.bg_label.setPixmap(pixmap)
        else:
            self.bg_label.setStyleSheet("background-color: #333;")

    def toggle_play(self):
        if self.player.state() == QMediaPlayer.PlayingState:
            self.player.pause()
            self.is_playing = False
            self.btn_play.setText("▶")
        else:
            self.player.play()
            self.is_playing = True
            self.btn_play.setText("⏸")

    def play_next(self):
        if not self.playlist: return
        next_idx = (self.current_index + 1) % len(self.playlist)
        self.play_track(next_idx)

    def play_prev(self):
        if not self.playlist: return
        prev_idx = (self.current_index - 1) if self.current_index > 0 else len(self.playlist) - 1
        self.play_track(prev_idx)

    def on_media_status_changed(self, status):
        if status == QMediaPlayer.EndOfMedia:
            self.play_next()

    def on_player_error(self, error):
        error_msg = self.player.errorString()
        if error_msg:
            error_text = f"播放器错误: {error_msg}"
        else:
            error_text = "播放器错误: 无法播放此文件"
        self.append_log(error_text)
        # Only show warning if error message is meaningful
        if error_msg and error_msg.strip():
            self.show_warning("播放错误", error_text)
        # Auto skip to next track after a delay
        QTimer.singleShot(1000, self.play_next)

    def on_position_changed(self, position):
        if not self.slider.isSliderDown():
            self.slider.setValue(position)
        self.lbl_curr.setText(self.format_time(position))
        self.sync_lyrics(position / 1000.0)

    def on_duration_changed(self, duration):
        self.slider.setRange(0, duration)
        self.lbl_total.setText(self.format_time(duration))

    def set_position(self, position):
        self.player.setPosition(position)

    def format_time(self, ms):
        seconds = (ms // 1000) % 60
        minutes = (ms // 60000)
        return f"{minutes:02}:{seconds:02}"

    # --- Lyrics Logic ---
    def on_lyrics_loaded(self, track_id, text):
        self.lyrics_data = []
        regex = re.compile(r'\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)')
        
        lines = text.split('\n')
        for line in lines:
            match = regex.match(line)
            if match:
                minutes = int(match.group(1))
                seconds = int(match.group(2))
                ms = int(match.group(3))
                if len(match.group(3)) == 2: ms *= 10
                time_sec = minutes * 60 + seconds + ms / 1000.0
                content = match.group(4).strip()
                if content:
                    self.lyrics_data.append((time_sec, content))
        
        # cache raw lyrics text
        key = str(track_id)
        try:
            self.lyrics_cache[key] = text
        except Exception:
            pass

        self.lyrics_list.clear()
        if not self.lyrics_data:
            self.lyrics_list.addItem("暂无歌词 / 纯音乐")
            return
            
        for _, content in self.lyrics_data:
            self.lyrics_list.addItem(content)
            
        for i in range(self.lyrics_list.count()):
            it = self.lyrics_list.item(i)
            it.setTextAlignment(Qt.AlignCenter)
            # apply default font & color
            it.setFont(self.lyrics_font)
            it.setForeground(self.lyrics_color)

    def sync_lyrics(self, current_time_sec):
        if not self.lyrics_data: return
        
        active_idx = -1
        for i, (time_val, _) in enumerate(self.lyrics_data):
            if time_val > current_time_sec:
                break
            active_idx = i
            
        if active_idx != -1:
            self.lyrics_list.setCurrentRow(active_idx)
            
            for i in range(self.lyrics_list.count()):
                item = self.lyrics_list.item(i)
                if i == active_idx:
                    item.setForeground(QColor(self.accent_color))
                    font = QFont(self.lyrics_font)
                    font.setBold(True)
                    # slightly larger for active
                    try:
                        font.setPointSize(max(self.lyrics_font.pointSize() + 4, 14))
                    except Exception:
                        font.setPointSize(16)
                    item.setFont(font)
                else:
                    item.setForeground(self.lyrics_color)
                    font = QFont(self.lyrics_font)
                    font.setBold(False)
                    item.setFont(font)
            
            self.lyrics_list.scrollToItem(self.lyrics_list.item(active_idx), QListWidget.PositionAtCenter)

if __name__ == '__main__':
    # Support command-line restore: `python YunDevMusic.py --restore`
    if '--restore' in sys.argv:
        ensure_data_dir()
        restore_factory_files()
        save_settings(DEFAULT_SETTINGS.copy())
        print('Factory settings restored.')
        sys.exit(0)

    # always hide console window on Windows (user requested)
    hide_console_on_windows()

    from PyQt5.QtWidgets import QApplication

    app = QApplication(sys.argv)

    # check stored privacy acceptance; show dialog only if not yet accepted
    ensure_data_dir()
    _settings = load_settings()
    if not _settings.get('privacy_accepted', False):
        priv = PrivacyDialog()
        ok = priv.exec_()
        if ok != QDialog.Accepted:
            sys.exit(0)

    # create player (do NOT start loading yet)
    player = MusicPlayer()

    # show welcome dialog; WelcomeDialog will start loading after shown
    welcome = WelcomeDialog(player)
    w_ok = welcome.exec_()
    if w_ok == QDialog.Accepted:
        player.show()
        sys.exit(app.exec_())
    else:
        sys.exit(0)

📄 许可证

©ZhiMa 2026 All Rights Reserved

享受音乐,享受生活! 🎵