YunDevMusic 音乐播放器 Pro
本文最后更新于 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. 尝试恢复出厂设置后重新配置
代码
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
享受音乐,享受生活! 🎵