diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6ed534c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+__pycache__/
+*.py[cod]
+.venv/
+venv/
+build/
+dist/
+*.spec
+*.exe
+.env
+.DS_Store
+Thumbs.db
diff --git a/README.md b/README.md
index 4708af1..00210c8 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,58 @@
# music123-desktop
+网易云音乐 Windows 原生桌面播放器,基于 Python、PySide6(Qt 6)和 QMediaPlayer。
+
+本仓库只包含桌面版源码,不包含网页端、服务端配置或已构建的 exe。
+
+## 功能
+
+- 网易云官方排行榜,首页数据缓存,点击秒开
+- 歌曲搜索、最近播放、播放队列
+- 导入公开歌单:链接、分享文案或数字 ID
+- 标准 / 较高 / 极高 / 无损音质切换
+- 逐行歌词、精确居中、点击歌词跳转
+- 全屏播放页、黑胶旋转、音量与播放进度控制
+- 播放栏显示当前播放歌单
+- 应用内检查更新、自动下载、SHA-256 校验和重启安装
+
+## 开发运行
+
+要求 Python 3.11+,推荐 Python 3.13。
+
+```powershell
+python -m venv .venv
+.\.venv\Scripts\Activate.ps1
+pip install -r requirements.txt
+python src\main.py
+```
+
+## 构建 Windows exe
+
+```powershell
+pip install pyinstaller
+powershell -ExecutionPolicy Bypass -File .\build.ps1
+```
+
+产物位于 `dist\网易云播放器.exe`。
+
+## 目录
+
+```text
+src/
+ main.py 主窗口、播放状态与导航
+ widgets.py 黑胶、歌词、歌曲列表等原生组件
+ theme.py QSS 视觉系统
+ core.py 网易云接口封装、歌词解析
+ import_dialog.py 导入歌单弹窗
+ updater.py 版本检查、自动下载与安装
+ version.py 版本号与更新地址
+ icon.ico 应用图标
+```
+
+## 说明
+
+本项目为非官方客户端,仅供学习交流。音乐内容、商标及相关权利归网易云音乐及原始权利人所有。
+
+## 许可证
+
+GNU Affero General Public License v3.0,详见 [LICENSE](LICENSE)。
diff --git a/build.ps1 b/build.ps1
new file mode 100644
index 0000000..b906ef5
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,21 @@
+param(
+ [string]$Python = "python"
+)
+
+$ErrorActionPreference = "Stop"
+Set-Location $PSScriptRoot
+
+& $Python -m PyInstaller `
+ --noconfirm `
+ --clean `
+ --windowed `
+ --onefile `
+ --name "网易云播放器" `
+ --icon "src\icon.ico" `
+ --add-data "src\icon.ico;." `
+ --distpath "dist" `
+ --workpath "build" `
+ --specpath "build" `
+ "src\main.py"
+
+Write-Host "Built: dist\网易云播放器.exe"
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..4b5753b
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+PySide6>=6.12.0,<7
+requests>=2.34.0,<3
diff --git a/src/core.py b/src/core.py
new file mode 100644
index 0000000..a67edc5
--- /dev/null
+++ b/src/core.py
@@ -0,0 +1,161 @@
+"""网易云播放器 · 数据层:API 封装、歌词解析、格式化工具"""
+import re
+import urllib.parse
+
+import requests
+
+API = "https://music.yuncishu.xyz/api"
+
+QUALITIES = [
+ ("standard", "标准"),
+ ("higher", "较高"),
+ ("exhigh", "极高"),
+ ("lossless", "无损"),
+]
+QUALITY_BR = {"standard": 128, "higher": 192, "exhigh": 320, "lossless": 2000}
+QUALITY_LABEL = dict(QUALITIES)
+
+CHARTS = [
+ (19723756, "飙升榜", "每天更新"),
+ (3778678, "热歌榜", "每周四更新"),
+ (3779629, "新歌榜", "每天更新"),
+ (2884035, "原创榜", "每周四更新"),
+]
+
+
+def http_get(path, **params):
+ r = requests.get(API + path, params=params, timeout=20)
+ j = r.json()
+ if isinstance(j, dict) and j.get("code") not in (None, 200):
+ raise RuntimeError("接口返回 code=%s" % j.get("code"))
+ return j
+
+
+def https(url):
+ return re.sub(r"^http://", "https://", url or "", flags=re.I)
+
+
+def cover_of(song, size=200):
+ pic = ((song.get("al") or {}).get("picUrl")) or ""
+ return "%s?param=%dy%d" % (https(pic), size, size) if pic else ""
+
+
+def artists_of(song):
+ return " / ".join(a.get("name", "") for a in (song.get("ar") or []))
+
+
+def album_of(song):
+ return (song.get("al") or {}).get("name") or ""
+
+
+def is_vip(song):
+ return song.get("fee") in (1, 4) and song.get("st") != 0
+
+
+def fmt_ms(ms):
+ s = max(0, int((ms or 0) / 1000))
+ return "%02d:%02d" % (s // 60, s % 60)
+
+
+def search_songs(keyword, limit=50):
+ j = http_get("/cloudsearch", keywords=keyword, limit=limit, type=1)
+ return (j.get("result") or {}).get("songs") or []
+
+
+def fetch_playlist(pid):
+ return http_get("/playlist/detail", id=pid).get("playlist") or {}
+
+
+def parse_playlist_ref(text):
+ """从公开歌单链接、分享文案或数字 ID 中解析歌单 ID。"""
+ value = (text or "").strip()
+ if not value:
+ raise ValueError("请输入歌单链接或数字 ID")
+ if re.fullmatch(r"\d{4,}", value):
+ return int(value)
+ urls = re.findall(r"https?://[^\s]+", value)
+ target = urls[0] if urls else value
+ parsed = urllib.parse.urlparse(target)
+ host = (parsed.hostname or "").lower()
+ allowed = ("music.163.com", "y.music.163.com", "163cn.tv", "www.163cn.tv")
+ if host and host not in allowed:
+ raise ValueError("只支持网易云音乐的公开歌单链接")
+ match = re.search(r"[?]id=(\d+)", target)
+ if match:
+ return int(match.group(1))
+ match = re.search(r"/playlist/(\d+)", target)
+ if match:
+ return int(match.group(1))
+ if target.startswith("http"):
+ if host not in allowed:
+ raise ValueError("链接域名不是网易云音乐")
+ response = requests.get(
+ target, timeout=12, allow_redirects=True,
+ headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) NeteasePlayer"},
+ )
+ final = response.url
+ match = re.search(r"[?]id=(\d+)", final) or re.search(r"/playlist/(\d+)", final)
+ if match:
+ return int(match.group(1))
+ raise ValueError("没有从链接中找到歌单 ID,请粘贴完整的网易云歌单链接")
+
+
+def playlist_songs(pid, limit=100):
+ """取榜单/歌单歌曲(优先 tracks,缺失时用 trackIds 补)"""
+ pl = fetch_playlist(pid)
+ songs = (pl.get("tracks") or [])[:limit]
+ if len(songs) < 5:
+ ids = [t["id"] for t in (pl.get("trackIds") or [])[:limit]]
+ if ids:
+ j = http_get("/song/detail", ids=",".join(str(i) for i in ids))
+ songs = j.get("songs") or []
+ return pl, songs
+
+
+def song_play_url(song_id, level):
+ """返回可播放地址;原生无直链时回退解灰通道"""
+ try:
+ j = http_get("/song/url/v1", id=song_id, level=level)
+ data = j.get("data") or []
+ if data and data[0].get("url"):
+ return https(data[0]["url"]), 320
+ except Exception:
+ pass
+ return "%s/meting/?type=url&id=%s&br=%s" % (API, song_id, QUALITY_BR.get(level, 320)), 0
+
+
+def fetch_lyric(song_id):
+ try:
+ j = http_get("/lyric", id=song_id)
+ except Exception:
+ return []
+ return parse_lrc((j.get("lrc") or {}).get("lyric") or "", (j.get("tlyric") or {}).get("lyric") or "")
+
+
+def parse_lrc(lrc, tlyric=""):
+ def parse(text):
+ out = []
+ for raw in (text or "").splitlines():
+ m = re.match(r"^((?:\[\d+:\d+(?:[.:]\d+)?\])+)(.*)$", raw)
+ if not m:
+ continue
+ content = m.group(2).strip()
+ if not content:
+ continue
+ for st in re.finditer(r"\[(\d+):(\d+(?:[.:]\d+)?)\]", m.group(1)):
+ t = int(st.group(1)) * 60 + float(st.group(2).replace(":", "."))
+ out.append({"t": t, "text": content, "tr": ""})
+ out.sort(key=lambda x: x["t"])
+ return out
+
+ lines = parse(lrc)
+ trans = parse(tlyric)
+ if trans:
+ ti = 0
+ for line in lines:
+ while ti < len(trans) and trans[ti]["t"] < line["t"] - 0.4:
+ ti += 1
+ if ti < len(trans) and abs(trans[ti]["t"] - line["t"]) <= 0.4:
+ line["tr"] = trans[ti]["text"]
+ ti += 1
+ return lines
diff --git a/src/icon.ico b/src/icon.ico
new file mode 100644
index 0000000..e9e9e8c
Binary files /dev/null and b/src/icon.ico differ
diff --git a/src/import_dialog.py b/src/import_dialog.py
new file mode 100644
index 0000000..e5c8c8f
--- /dev/null
+++ b/src/import_dialog.py
@@ -0,0 +1,104 @@
+"""导入歌单弹窗。"""
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QColor
+from PySide6.QtWidgets import (
+ QDialog, QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QLabel, QLineEdit,
+ QPushButton, QVBoxLayout, QWidget,
+)
+
+from widgets import make_icon, icon_button
+
+
+class ImportDialog(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.value = ""
+ self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint)
+ self.setAttribute(Qt.WA_TranslucentBackground)
+ self.setModal(True)
+ self.setFixedWidth(500)
+ self._build()
+
+ def _build(self):
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(18, 18, 18, 18)
+ card = QFrame(objectName="importCard")
+ shadow = QGraphicsDropShadowEffect(card)
+ shadow.setBlurRadius(36)
+ shadow.setOffset(0, 12)
+ shadow.setColor(QColor(28, 27, 31, 60))
+ card.setGraphicsEffect(shadow)
+ outer.addWidget(card)
+
+ layout = QVBoxLayout(card)
+ layout.setContentsMargins(26, 22, 26, 22)
+ layout.setSpacing(0)
+
+ top = QHBoxLayout()
+ top.setSpacing(12)
+ icon_box = QLabel()
+ icon_box.setFixedSize(40, 40)
+ icon_box.setPixmap(make_icon("playlist", "#ffffff", 20).pixmap(40, 40))
+ icon_box.setAlignment(Qt.AlignCenter)
+ icon_box.setStyleSheet("background:#e6493f; border-radius:11px;")
+ top.addWidget(icon_box)
+ title_box = QVBoxLayout()
+ title_box.setSpacing(2)
+ title_box.addWidget(QLabel("导入歌单", objectName="importTitle"))
+ title_box.addWidget(QLabel("把网易云的歌单带进桌面播放器", objectName="importSub"))
+ top.addLayout(title_box, 1)
+ close = icon_button("close", 32, 15, tooltip="关闭")
+ close.clicked.connect(self.reject)
+ top.addWidget(close)
+ layout.addLayout(top)
+ layout.addSpacing(22)
+
+ self.input = QLineEdit(objectName="importInput")
+ self.input.setPlaceholderText("粘贴歌单链接,或输入歌单 ID")
+ self.input.returnPressed.connect(self._submit)
+ self.input.textChanged.connect(lambda: self.error.hide() if hasattr(self, "error") else None)
+ layout.addWidget(self.input)
+ layout.addSpacing(8)
+ hint = QLabel(
+ "支持 music.163.com/playlist?id=…、分享链接或纯数字 ID · 单次最多导入前 1000 首",
+ objectName="importHint",
+ )
+ hint.setWordWrap(True)
+ layout.addWidget(hint)
+ layout.addSpacing(5)
+ self.error = QLabel("", objectName="importError")
+ self.error.hide()
+ layout.addWidget(self.error)
+ layout.addSpacing(18)
+
+ footer = QHBoxLayout()
+ footer.setSpacing(9)
+ footer.addStretch()
+ cancel = QPushButton("取消", objectName="importCancel")
+ cancel.setCursor(Qt.PointingHandCursor)
+ cancel.clicked.connect(self.reject)
+ footer.addWidget(cancel)
+ self.go = QPushButton("读取歌单", objectName="importGo")
+ self.go.setCursor(Qt.PointingHandCursor)
+ self.go.clicked.connect(self._submit)
+ footer.addWidget(self.go)
+ layout.addLayout(footer)
+
+ def _submit(self):
+ value = self.input.text().strip()
+ if not value:
+ self.error.setText("请先粘贴歌单链接或输入歌单 ID")
+ self.error.show()
+ self.input.setFocus()
+ return
+ self.value = value
+ self.accept()
+
+ def showEvent(self, event):
+ super().showEvent(event)
+ parent = self.parentWidget()
+ if parent:
+ x = parent.x() + (parent.width() - self.width()) // 2
+ y = parent.y() + (parent.height() - self.height()) // 2
+ self.move(max(0, x), max(0, y))
+ self.input.setFocus()
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..c072dc6
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,1298 @@
+"""网易云播放器 · 原生桌面版(PySide6 / Qt6,无网页界面)"""
+import datetime
+import json
+import os
+import random
+import sys
+
+import requests
+from PySide6.QtCore import Qt, QUrl, QSize, QTimer, QRunnable, QThreadPool, Signal, QObject
+from PySide6.QtGui import QPixmap, QIcon, QColor
+from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
+from PySide6.QtWidgets import (
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+ QLineEdit, QStackedWidget, QListWidget, QListWidgetItem, QSlider, QComboBox,
+ QGridLayout, QSizePolicy, QProgressBar, QDialog,
+)
+
+import core
+import updater
+from import_dialog import ImportDialog
+from version import APP_NAME, APP_VERSION
+from theme import QSS, ACCENT
+from widgets import (
+ make_icon, rounded_pixmap, DiscWidget, ClickSlider, LyricView, TrackTable, icon_button,
+ CoverBackdrop,
+)
+
+
+# ============================ 异步任务 ============================
+class Worker(QRunnable):
+ class Sig(QObject):
+ ok = Signal(object)
+ err = Signal(str)
+
+ def __init__(self, fn):
+ super().__init__()
+ self.fn = fn
+ self.sig = Worker.Sig()
+
+ def run(self):
+ try:
+ result = self.fn()
+ except Exception as exc: # noqa: BLE001
+ try:
+ self.sig.err.emit(str(exc))
+ except RuntimeError:
+ pass # 进程退出时接收方可能已销毁
+ return
+ try:
+ self.sig.ok.emit(result)
+ except RuntimeError:
+ pass
+
+
+POOL = QThreadPool.globalInstance()
+POOL.setMaxThreadCount(16)
+_WORKERS = set()
+
+# 封面下载独立限流,绝不能挤占榜单/歌词等关键请求
+IMAGE_POOL = QThreadPool()
+IMAGE_POOL.setMaxThreadCount(4)
+_IMAGE_WORKERS = set()
+
+
+def run_async(fn, on_ok, on_err):
+ worker = Worker(fn)
+ worker.sig.ok.connect(on_ok)
+ worker.sig.err.connect(on_err)
+ worker.sig.ok.connect(lambda *_: _WORKERS.discard(worker))
+ worker.sig.err.connect(lambda *_: _WORKERS.discard(worker))
+ _WORKERS.add(worker)
+ POOL.start(worker)
+
+
+def run_image_async(fn, on_ok, on_err):
+ worker = Worker(fn)
+ worker.sig.ok.connect(on_ok)
+ worker.sig.err.connect(on_err)
+ worker.sig.ok.connect(lambda *_: _IMAGE_WORKERS.discard(worker))
+ worker.sig.err.connect(lambda *_: _IMAGE_WORKERS.discard(worker))
+ _IMAGE_WORKERS.add(worker)
+ IMAGE_POOL.start(worker)
+
+
+def fetch_bytes(url):
+ if not url:
+ return None
+ try:
+ response = requests.get(url, timeout=12)
+ response.raise_for_status()
+ return response.content
+ except Exception:
+ return None
+
+
+# ============================ 主窗口 ============================
+class MainWindow(QMainWindow):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("%s %s" % (APP_NAME, APP_VERSION))
+ self.resize(1280, 820)
+ self.setMinimumSize(1024, 680)
+ base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
+ icon_path = os.path.join(base, "icon.ico")
+ if os.path.exists(icon_path):
+ self.setWindowIcon(QIcon(icon_path))
+
+ self.queue = []
+ self.q_index = -1
+ self.current = None
+ self.stack_context_name = "发现音乐"
+ self.current_playlist_name = ""
+ self.mode = "list"
+ self.quality = "exhigh"
+ self.volume = 78
+ self.lyric_lines = []
+ self.lyric_index = -1
+ self.lyric_cache = {}
+ self.recent = self._load_recent()
+ self.stack_songs = []
+ self.view_token = 0
+ self._syncing_quality = False
+ self._syncing_volume = False
+ self.table_generation = 0
+ self.chart_cache = {}
+ self.chart_inflight = set()
+ self.chart_waiters = {}
+ self.cover_songs = []
+ self.cover_loaded = set()
+ self.cover_pending = set()
+ self.update_dialog = None
+ self._checking_update = False
+
+ self.player = QMediaPlayer(self)
+ self.audio_out = QAudioOutput(self)
+ self.player.setAudioOutput(self.audio_out)
+ self.audio_out.setVolume(self.volume / 100)
+ self.player.positionChanged.connect(self._on_position)
+ self.player.durationChanged.connect(self._on_duration)
+ self.player.playbackStateChanged.connect(self._on_state)
+ self.player.errorOccurred.connect(self._on_media_error)
+
+ self._build_ui()
+ self._switch_view("charts")
+ self._load_charts()
+ if not os.environ.get("PLAYER_DISABLE_UPDATE"):
+ QTimer.singleShot(2500, lambda: self._check_update(manual=False))
+
+ self.lyric_timer = QTimer(self)
+ self.lyric_timer.setInterval(100)
+ self.lyric_timer.timeout.connect(self._sync_lyric)
+ self.lyric_timer.start()
+
+ # ---------- 界面构建 ----------
+ def _build_ui(self):
+ root = QWidget(objectName="root")
+ self.setCentralWidget(root)
+ outer = QVBoxLayout(root)
+ outer.setContentsMargins(0, 0, 0, 0)
+ outer.setSpacing(0)
+
+ body = QHBoxLayout()
+ body.setContentsMargins(0, 0, 0, 0)
+ body.setSpacing(0)
+ outer.addLayout(body, 1)
+ body.addWidget(self._build_sidebar())
+
+ main = QWidget(objectName="main")
+ layout = QVBoxLayout(main)
+ layout.setContentsMargins(34, 28, 34, 16)
+ layout.setSpacing(18)
+
+ header = QHBoxLayout()
+ header.setSpacing(10)
+ titles = QVBoxLayout()
+ titles.setSpacing(2)
+ self.page_eyebrow = QLabel("LIBRARY / DISCOVER", objectName="pageEyebrow")
+ self.page_title = QLabel("发现音乐", objectName="pageTitle")
+ self.page_meta = QLabel("网易云音乐官方榜单", objectName="pageMeta")
+ titles.addWidget(self.page_eyebrow)
+ titles.addWidget(self.page_title)
+ titles.addSpacing(4)
+ titles.addWidget(self.page_meta)
+ header.addLayout(titles, 1)
+
+ self.btn_import = QPushButton("导入歌单", objectName="textBtn")
+ self.btn_import.setCursor(Qt.PointingHandCursor)
+ self.btn_import.setIcon(make_icon("plus", "#625f5a", 13))
+ self.btn_import.setIconSize(QSize(13, 13))
+ self.btn_import.setToolTip("粘贴网易云歌单链接(Ctrl+I)")
+ self.btn_import.clicked.connect(self._open_import_dialog)
+ header.addWidget(self.btn_import)
+
+ self.btn_back = icon_button("back", 34, 17, tooltip="返回榜单")
+ self.btn_back.clicked.connect(lambda: self._switch_view("charts"))
+ self.btn_back.setVisible(False)
+ header.addWidget(self.btn_back)
+
+ self.btn_play_all = QPushButton("播放全部", objectName="accent")
+ self.btn_play_all.setCursor(Qt.PointingHandCursor)
+ self.btn_play_all.setIcon(make_icon("play", "#ffffff", 13))
+ self.btn_play_all.setIconSize(QSize(13, 13))
+ self.btn_play_all.clicked.connect(self._play_all)
+ self.btn_play_all.setVisible(False)
+ header.addWidget(self.btn_play_all)
+
+ self.btn_refresh = icon_button("refresh", 34, 16, tooltip="刷新榜单")
+ self.btn_refresh.clicked.connect(self._load_charts)
+ header.addWidget(self.btn_refresh)
+ layout.addLayout(header)
+
+ self.search_box = QWidget()
+ search_layout = QHBoxLayout(self.search_box)
+ search_layout.setContentsMargins(0, 0, 0, 0)
+ search_layout.setSpacing(10)
+ self.search_input = QLineEdit(objectName="search")
+ self.search_input.setPlaceholderText("歌曲、歌手或专辑")
+ self.search_input.setClearButtonEnabled(True)
+ self.search_input.returnPressed.connect(self._do_search)
+ search_button = icon_button("search", 40, 17, tooltip="搜索")
+ search_button.clicked.connect(self._do_search)
+ search_layout.addWidget(self.search_input, 1)
+ search_layout.addWidget(search_button)
+ self.search_box.setVisible(False)
+ layout.addWidget(self.search_box)
+
+ self.stack = QStackedWidget()
+ layout.addWidget(self.stack, 1)
+ self.charts_page = self._build_charts_page()
+ self.stack.addWidget(self.charts_page)
+ self.table = TrackTable()
+ self.table.play_requested.connect(self._play_from_list)
+ self.table.verticalScrollBar().valueChanged.connect(self._load_visible_covers)
+ self.stack.addWidget(self.table)
+ self.empty_page = self._build_empty_page()
+ self.stack.addWidget(self.empty_page)
+
+ body.addWidget(main, 1)
+ outer.addWidget(self._build_bar())
+
+ self.fs = self._build_fullscreen()
+ self.fs.setParent(root)
+ self.fs.hide()
+
+ def _build_sidebar(self):
+ side = QWidget(objectName="side")
+ side.setFixedWidth(224)
+ layout = QVBoxLayout(side)
+ layout.setContentsMargins(14, 22, 14, 16)
+ layout.setSpacing(0)
+
+ brand = QHBoxLayout()
+ brand.setContentsMargins(8, 0, 4, 28)
+ brand.setSpacing(11)
+ logo = QLabel()
+ logo.setFixedSize(32, 32)
+ logo.setPixmap(self._logo_pixmap(32))
+ brand.addWidget(logo)
+ text = QVBoxLayout()
+ text.setSpacing(3)
+ text.addWidget(QLabel("网易云播放器", objectName="brandName"))
+ text.addWidget(QLabel("DESKTOP / 01", objectName="brandSub"))
+ brand.addLayout(text)
+ brand.addStretch()
+ layout.addLayout(brand)
+
+ section = QLabel("你的音乐", objectName="sideSection")
+ section.setContentsMargins(12, 0, 0, 8)
+ layout.addWidget(section)
+ self.nav_btns = {}
+ for key, label, icon in (
+ ("charts", "发现音乐", "chart"),
+ ("search", "搜索", "search"),
+ ("recent", "最近播放", "clock"),
+ ):
+ button = QPushButton(" " + label, objectName="nav")
+ button.setIcon(make_icon(icon, "#a9abb2", 17))
+ button.setIconSize(QSize(17, 17))
+ button.setCursor(Qt.PointingHandCursor)
+ button.clicked.connect(lambda _, k=key: self._switch_view(k))
+ layout.addWidget(button)
+ self.nav_btns[key] = button
+
+ queue_header = QHBoxLayout()
+ queue_header.setContentsMargins(12, 28, 6, 8)
+ queue_header.addWidget(QLabel("当前队列", objectName="sideSection"))
+ queue_header.addStretch()
+ clear = QPushButton("清空", objectName="clearBtn")
+ clear.setCursor(Qt.PointingHandCursor)
+ clear.clicked.connect(self._clear_queue)
+ queue_header.addWidget(clear)
+ layout.addLayout(queue_header)
+
+ self.queue_list = QListWidget(objectName="queue")
+ self.queue_list.setCursor(Qt.PointingHandCursor)
+ self.queue_list.viewport().setCursor(Qt.PointingHandCursor)
+ self.queue_list.itemClicked.connect(self._on_queue_click)
+ layout.addWidget(self.queue_list, 1)
+ self.queue_empty = QLabel("播放的歌曲会出现在这里", objectName="queueEmpty")
+ self.queue_empty.setContentsMargins(12, 4, 8, 8)
+ layout.addWidget(self.queue_empty)
+
+ layout.addSpacing(8)
+ version_row = QHBoxLayout()
+ version_row.setContentsMargins(10, 8, 2, 0)
+ self.version_label = QLabel("v%s" % APP_VERSION, objectName="versionLabel")
+ version_row.addWidget(self.version_label)
+ version_row.addStretch()
+ self.version_button = QPushButton("检查更新", objectName="versionBtn")
+ self.version_button.setCursor(Qt.PointingHandCursor)
+ self.version_button.clicked.connect(lambda: self._check_update(manual=True))
+ version_row.addWidget(self.version_button)
+ layout.addLayout(version_row)
+ return side
+
+ @staticmethod
+ def _logo_pixmap(size):
+ icon = make_icon("netease", ACCENT, size)
+ return icon.pixmap(QSize(size, size))
+
+ def _build_charts_page(self):
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(0, 6, 0, 0)
+ layout.setSpacing(16)
+ grid = QGridLayout()
+ grid.setSpacing(16)
+ self.chart_cards = []
+ for i, (pid, name, period) in enumerate(core.CHARTS):
+ card = QPushButton(objectName="chartCard")
+ card.setMinimumHeight(126)
+ card.setCursor(Qt.PointingHandCursor)
+ card_layout = QHBoxLayout(card)
+ card_layout.setContentsMargins(20, 18, 20, 18)
+ card_layout.setSpacing(20)
+ cover = QLabel()
+ cover.setFixedSize(88, 88)
+ cover.setStyleSheet("background:#dfdcd6; border-radius:10px;")
+ card_layout.addWidget(cover)
+ text = QVBoxLayout()
+ text.setSpacing(8)
+ index = QLabel("%02d" % (i + 1), objectName="chartIndex")
+ title = QLabel(name, objectName="chartName")
+ meta = QLabel(period, objectName="chartMeta")
+ text.addWidget(index)
+ text.addWidget(title)
+ text.addWidget(meta)
+ card_layout.addLayout(text, 1)
+ arrow = QLabel("↗")
+ arrow.setStyleSheet("color:#a39d94; font-size:18px;")
+ card_layout.addWidget(arrow, 0, Qt.AlignTop)
+ card.clicked.connect(lambda _, p=pid, n=name: self._open_chart(p, n))
+ grid.addWidget(card, i // 2, i % 2)
+ self.chart_cards.append((pid, period, cover, meta, card))
+ grid.setColumnStretch(0, 1)
+ grid.setColumnStretch(1, 1)
+ grid.setRowStretch(0, 1)
+ grid.setRowStretch(1, 1)
+ layout.addLayout(grid, 1)
+ note = QLabel("OFFICIAL CHARTS · 与网易云音乐同步更新")
+ note.setStyleSheet("color:#aaa59c; font-size:10px; letter-spacing:1px;")
+ note.setAlignment(Qt.AlignRight)
+ layout.addWidget(note)
+ return page
+
+ def _build_empty_page(self):
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(20, 40, 20, 60)
+ layout.addStretch()
+ icon = QLabel()
+ icon.setPixmap(make_icon("music", "#b5afa6", 38).pixmap(QSize(38, 38)))
+ icon.setAlignment(Qt.AlignCenter)
+ layout.addWidget(icon)
+ layout.addSpacing(16)
+ self.empty_title = QLabel("从这里开始", objectName="emptyTitle")
+ self.empty_title.setAlignment(Qt.AlignCenter)
+ layout.addWidget(self.empty_title)
+ layout.addSpacing(6)
+ self.empty_meta = QLabel("选择榜单,或搜索一首喜欢的歌", objectName="emptyMeta")
+ self.empty_meta.setAlignment(Qt.AlignCenter)
+ layout.addWidget(self.empty_meta)
+ layout.addSpacing(18)
+ self.empty_progress = QProgressBar(objectName="emptyProgress")
+ self.empty_progress.setRange(0, 0)
+ self.empty_progress.setTextVisible(False)
+ self.empty_progress.setFixedSize(180, 4)
+ self.empty_progress.hide()
+ layout.addWidget(self.empty_progress, 0, Qt.AlignHCenter)
+ self._empty_retry = None
+ self.empty_action = QPushButton("重新加载", objectName="accent")
+ self.empty_action.setCursor(Qt.PointingHandCursor)
+ self.empty_action.setFixedWidth(120)
+ self.empty_action.clicked.connect(lambda: self._empty_retry() if self._empty_retry else None)
+ self.empty_action.hide()
+ layout.addSpacing(14)
+ layout.addWidget(self.empty_action, 0, Qt.AlignHCenter)
+ layout.addStretch()
+ return page
+
+ def _build_bar(self):
+ bar = QWidget(objectName="bar")
+ bar.setFixedHeight(94)
+ layout = QHBoxLayout(bar)
+ layout.setContentsMargins(22, 12, 24, 12)
+ layout.setSpacing(24)
+
+ left_widget = QWidget()
+ left_widget.setFixedWidth(270)
+ left = QHBoxLayout(left_widget)
+ left.setContentsMargins(0, 0, 0, 0)
+ left.setSpacing(13)
+ self.bar_cover = QLabel()
+ self.bar_cover.setFixedSize(56, 56)
+ self.bar_cover.setCursor(Qt.PointingHandCursor)
+ self.bar_cover.setToolTip("打开播放页")
+ self.bar_cover.setStyleSheet("background:#e5e1db; border-radius:10px;")
+ self.bar_cover.mousePressEvent = lambda event: self._toggle_fullscreen()
+ left.addWidget(self.bar_cover)
+ info = QVBoxLayout()
+ info.setSpacing(4)
+ self.bar_name = QLabel("还没有播放歌曲", objectName="barName")
+ self.bar_artist = QLabel("从榜单中挑选一首", objectName="barArtist")
+ self.bar_hint = QLabel("单击歌曲即可播放", objectName="barHint")
+ info.addWidget(self.bar_name)
+ info.addWidget(self.bar_artist)
+ info.addWidget(self.bar_hint)
+ left.addLayout(info, 1)
+ layout.addWidget(left_widget)
+
+ center = QVBoxLayout()
+ center.setSpacing(6)
+ controls = QHBoxLayout()
+ controls.setSpacing(10)
+ controls.addStretch()
+ self.btn_prev = icon_button("prev", 34, 18, tooltip="上一首")
+ self.btn_prev.clicked.connect(self._prev)
+ self.btn_play = QPushButton(objectName="playBtn")
+ self.btn_play.setFixedSize(42, 42)
+ self.btn_play.setCursor(Qt.PointingHandCursor)
+ self.btn_play.setToolTip("播放 / 暂停(空格)")
+ self.btn_play.setIcon(make_icon("play", "#ffffff", 18))
+ self.btn_play.setIconSize(QSize(18, 18))
+ self.btn_play.clicked.connect(self._toggle_play)
+ self.btn_next = icon_button("next", 34, 18, tooltip="下一首")
+ self.btn_next.clicked.connect(self._next)
+ controls.addWidget(self.btn_prev)
+ controls.addWidget(self.btn_play)
+ controls.addWidget(self.btn_next)
+ controls.addStretch()
+ center.addLayout(controls)
+
+ progress_row = QHBoxLayout()
+ progress_row.setSpacing(10)
+ self.t_now = QLabel("00:00", objectName="timeLabel")
+ self.t_now.setFixedWidth(38)
+ self.t_now.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
+ self.progress = ClickSlider(Qt.Horizontal)
+ self.progress.setRange(0, 1000)
+ self.progress.sliderMoved.connect(self._seek_preview)
+ self.progress.sliderReleased.connect(self._seek_commit)
+ self.t_dur = QLabel("00:00", objectName="timeLabel")
+ self.t_dur.setFixedWidth(38)
+ progress_row.addWidget(self.t_now)
+ progress_row.addWidget(self.progress, 1)
+ progress_row.addWidget(self.t_dur)
+ center.addLayout(progress_row)
+ layout.addLayout(center, 1)
+
+ right_widget = QWidget()
+ right_widget.setFixedWidth(290)
+ right_column = QVBoxLayout(right_widget)
+ right_column.setContentsMargins(0, 1, 0, 0)
+ right_column.setSpacing(4)
+ right = QHBoxLayout()
+ right.setContentsMargins(0, 0, 0, 0)
+ right.setSpacing(7)
+ right.addStretch()
+ self.btn_mode = icon_button("repeat", 32, 17, tooltip="列表循环")
+ self.btn_mode.clicked.connect(self._cycle_mode)
+ right.addWidget(self.btn_mode)
+ self.quality_box = QComboBox()
+ self.quality_box.setFixedWidth(72)
+ self.quality_box.setCursor(Qt.PointingHandCursor)
+ self.quality_box.setToolTip("播放音质")
+ for key, label in core.QUALITIES:
+ self.quality_box.addItem(label, key)
+ self.quality_box.setCurrentIndex(2)
+ self.quality_box.currentIndexChanged.connect(self._on_quality_changed)
+ right.addWidget(self.quality_box)
+ self.btn_lyric = icon_button("expand", 32, 17, tooltip="打开播放页(L)")
+ self.btn_lyric.clicked.connect(self._toggle_fullscreen)
+ right.addWidget(self.btn_lyric)
+ self.btn_mute = icon_button("volume", 32, 17, tooltip="静音")
+ self.btn_mute.clicked.connect(self._toggle_mute)
+ right.addWidget(self.btn_mute)
+ self.vol_slider = QSlider(Qt.Horizontal, objectName="vol")
+ self.vol_slider.setRange(0, 100)
+ self.vol_slider.setValue(self.volume)
+ self.vol_slider.setFixedWidth(72)
+ self.vol_slider.setCursor(Qt.PointingHandCursor)
+ self.vol_slider.setToolTip("音量")
+ self.vol_slider.valueChanged.connect(self._on_volume)
+ right.addWidget(self.vol_slider)
+ right_column.addLayout(right)
+ self.bar_playlist = QLabel("歌单 · 未播放", objectName="barPlaylist")
+ self.bar_playlist.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
+ self.bar_playlist.setFixedHeight(14)
+ self.bar_playlist.setToolTip("当前播放队列来源")
+ right_column.addWidget(self.bar_playlist)
+ layout.addWidget(right_widget)
+ return bar
+
+ def _build_fullscreen(self):
+ fs = QWidget(objectName="fs")
+ self.fs_bg = CoverBackdrop(fs)
+ self.fs_bg.lower()
+ layout = QVBoxLayout(fs)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ header = QWidget()
+ header_layout = QHBoxLayout(header)
+ header_layout.setContentsMargins(36, 24, 30, 8)
+ text = QVBoxLayout()
+ text.setSpacing(5)
+ self.fs_name = QLabel("尚未播放", objectName="fsName")
+ self.fs_artist = QLabel("选择一首歌曲,让音乐开始", objectName="fsArtist")
+ text.addWidget(self.fs_name)
+ text.addWidget(self.fs_artist)
+ header_layout.addLayout(text, 1)
+ close = icon_button("close", 40, 19, tooltip="返回(Esc)")
+ close.clicked.connect(self._toggle_fullscreen)
+ header_layout.addWidget(close)
+ layout.addWidget(header)
+
+ body = QHBoxLayout()
+ body.setContentsMargins(56, 4, 56, 4)
+ body.setSpacing(64)
+ body.addStretch()
+ disc_column = QVBoxLayout()
+ disc_column.addStretch()
+ self.fs_disc = DiscWidget(300)
+ disc_column.addWidget(self.fs_disc, 0, Qt.AlignCenter)
+ disc_column.addStretch()
+ body.addLayout(disc_column)
+
+ self.lyric_view = LyricView()
+ self.lyric_view.setFixedWidth(520)
+ self.lyric_view.seek_requested.connect(self._lyric_seek)
+ self.lyric_list = self.lyric_view
+ body.addWidget(self.lyric_view, 1)
+ body.addStretch()
+ layout.addLayout(body, 1)
+
+ # 与网页端一致的单行底部控制:切歌 / 进度 / 音质 / 音量
+ footer = QWidget(objectName="fsControl")
+ foot = QHBoxLayout(footer)
+ foot.setContentsMargins(36, 10, 36, 24)
+ foot.setSpacing(12)
+ previous = icon_button("prev", 38, 19, tooltip="上一首")
+ previous.clicked.connect(self._prev)
+ self.fs_play = QPushButton(objectName="playBtn")
+ self.fs_play.setFixedSize(46, 46)
+ self.fs_play.setCursor(Qt.PointingHandCursor)
+ self.fs_play.setStyleSheet(
+ "QPushButton#playBtn{background:rgba(255,255,255,210);border:1px solid #d8d3cc;border-radius:23px;}"
+ "QPushButton#playBtn:hover{background:#e6493f;border-color:#e6493f;}"
+ )
+ self.fs_play.setIcon(make_icon("play", "#242322", 20))
+ self.fs_play.setIconSize(QSize(20, 20))
+ self.fs_play.clicked.connect(self._toggle_play)
+ following = icon_button("next", 38, 19, tooltip="下一首")
+ following.clicked.connect(self._next)
+ foot.addWidget(previous)
+ foot.addWidget(self.fs_play)
+ foot.addWidget(following)
+ foot.addSpacing(4)
+ self.fs_now = QLabel("00:00", objectName="timeLabel")
+ self.fs_now.setFixedWidth(38)
+ self.fs_now.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
+ self.fs_progress = ClickSlider(Qt.Horizontal)
+ self.fs_progress.setRange(0, 1000)
+ self.fs_progress.sliderMoved.connect(self._seek_preview)
+ self.fs_progress.sliderReleased.connect(self._seek_commit)
+ self.fs_dur = QLabel("00:00", objectName="timeLabel")
+ self.fs_dur.setFixedWidth(38)
+ foot.addWidget(self.fs_now)
+ foot.addWidget(self.fs_progress, 1)
+ foot.addWidget(self.fs_dur)
+ foot.addSpacing(8)
+ self.fs_quality = QComboBox()
+ self.fs_quality.setFixedWidth(76)
+ self.fs_quality.setCursor(Qt.PointingHandCursor)
+ self.fs_quality.setToolTip("播放音质")
+ for key, label in core.QUALITIES:
+ self.fs_quality.addItem(label, key)
+ self.fs_quality.setCurrentIndex(2)
+ self.fs_quality.currentIndexChanged.connect(self._on_quality_changed)
+ foot.addWidget(self.fs_quality)
+ self.fs_mute = icon_button("volume", 34, 18, tooltip="音量")
+ self.fs_mute.clicked.connect(self._toggle_mute)
+ foot.addWidget(self.fs_mute)
+ self.fs_vol = QSlider(Qt.Horizontal, objectName="vol")
+ self.fs_vol.setRange(0, 100)
+ self.fs_vol.setValue(self.volume)
+ self.fs_vol.setFixedWidth(100)
+ self.fs_vol.setCursor(Qt.PointingHandCursor)
+ self.fs_vol.valueChanged.connect(self._on_volume)
+ foot.addWidget(self.fs_vol)
+ layout.addWidget(footer)
+ return fs
+
+ # ---------- 视图 ----------
+ def _switch_view(self, key):
+ self.view_token += 1
+ for name, button in self.nav_btns.items():
+ active = name == key
+ button.setProperty("active", "true" if active else "false")
+ icon = {"charts": "chart", "search": "search", "recent": "clock"}[name]
+ button.setIcon(make_icon(icon, "#ffffff" if active else "#a9abb2", 17))
+ button.style().unpolish(button)
+ button.style().polish(button)
+ self.search_box.setVisible(key == "search")
+ self.btn_refresh.setVisible(key == "charts")
+ self.btn_back.setVisible(False)
+ self.btn_play_all.setVisible(False)
+ if key == "charts":
+ self.page_eyebrow.setText("LIBRARY / DISCOVER")
+ self.page_title.setText("发现音乐")
+ self.page_meta.setText("网易云音乐官方榜单")
+ self.stack_context_name = "发现音乐"
+ self.stack.setCurrentWidget(self.charts_page)
+ elif key == "search":
+ self.page_eyebrow.setText("LIBRARY / SEARCH")
+ self.page_title.setText("搜索")
+ self.page_meta.setText("在网易云音乐曲库中寻找声音")
+ self.stack_context_name = "搜索"
+ self._show_empty("搜索一首歌", "输入歌曲、歌手或专辑名称,按回车开始")
+ self.search_input.setFocus()
+ else:
+ self.page_eyebrow.setText("LIBRARY / HISTORY")
+ self.page_title.setText("最近播放")
+ self.page_meta.setText("保存在这台电脑上的播放记录")
+ self.stack_context_name = "最近播放"
+ if not self.recent:
+ self._show_empty("还没有播放记录", "播放过的歌曲会自动保存在这里")
+ else:
+ self.table.set_songs(self.recent)
+ self.table.set_playing(self.current["id"] if self.current else None)
+ self._load_table_covers(self.recent)
+ self.stack_songs = self.recent
+ self.stack.setCurrentWidget(self.table)
+ self.btn_play_all.setVisible(True)
+
+ def _show_empty(self, title, meta, retry=None, busy=False):
+ self.empty_title.setText(title)
+ self.empty_meta.setText(meta)
+ self._empty_retry = retry
+ self.empty_action.setVisible(bool(retry))
+ self.empty_progress.setVisible(bool(busy))
+ self.stack.setCurrentWidget(self.empty_page)
+
+ # ---------- 榜单与搜索 ----------
+ def _load_charts(self):
+ def fetch(pid):
+ playlist = core.fetch_playlist(pid)
+ picture = playlist.get("coverImgUrl") or ""
+ raw = fetch_bytes(core.https(picture) + "?param=180y180") if picture else None
+ return playlist, raw
+
+ for pid, period, cover, meta, card in self.chart_cards:
+ self.chart_inflight.add(pid)
+
+ def ok(data, period=period, cover=cover, meta=meta, pid=pid):
+ playlist, raw = data
+ self.chart_inflight.discard(pid)
+ self.chart_cache[pid] = playlist
+ if raw:
+ pixmap = QPixmap()
+ if pixmap.loadFromData(raw):
+ cover.setPixmap(rounded_pixmap(pixmap, 88, 10))
+ meta.setText("%s · %d 首" % (period, playlist.get("trackCount") or 0))
+ for waiter in self.chart_waiters.pop(pid, []):
+ waiter(playlist, None)
+
+ def err(error, pid=pid):
+ self.chart_inflight.discard(pid)
+ for waiter in self.chart_waiters.pop(pid, []):
+ waiter(None, error)
+ run_async(lambda p=pid: fetch(p), ok, err)
+
+ def _load_table_covers(self, songs):
+ self.table_generation += 1
+ self.cover_songs = list(songs)
+ self.cover_loaded = set()
+ self.cover_pending = set()
+ QTimer.singleShot(0, self._load_visible_covers)
+
+ def _load_visible_covers(self, *_):
+ """只加载视口附近封面,并用独立线程池,不阻塞榜单和播放请求。"""
+ if not self.cover_songs:
+ return
+ total = len(self.cover_songs)
+ first = self.table.rowAt(0)
+ last = self.table.rowAt(max(0, self.table.viewport().height() - 2))
+ if first < 0:
+ first = 0
+ if last < 0:
+ last = min(total - 1, 18)
+ start = max(0, first - 2)
+ end = min(total - 1, last + 5)
+ generation = self.table_generation
+ for index in range(start, end + 1):
+ if index in self.cover_loaded or index in self.cover_pending:
+ continue
+ url = core.cover_of(self.cover_songs[index], 96)
+ if not url:
+ continue
+ self.cover_pending.add(index)
+
+ def ok(raw, index=index, generation=generation):
+ self.cover_pending.discard(index)
+ if generation != self.table_generation or not raw:
+ return
+ pixmap = QPixmap()
+ if pixmap.loadFromData(raw):
+ self.table.set_cover(index, pixmap)
+ self.cover_loaded.add(index)
+ run_image_async(lambda u=url: fetch_bytes(u), ok,
+ lambda error, index=index: self.cover_pending.discard(index))
+
+ def _check_update(self, manual=False):
+ if os.environ.get("PLAYER_DISABLE_UPDATE"):
+ return
+ if self.update_dialog is not None:
+ self.update_dialog.show()
+ self.update_dialog.raise_()
+ return
+ if self._checking_update:
+ return
+ self._checking_update = True
+ if manual:
+ self.version_button.setText("检查中…")
+
+ def ok(info):
+ self._checking_update = False
+ if updater.is_newer(info.get("version"), APP_VERSION):
+ self.version_button.setText("发现新版本")
+ self._show_update(info)
+ else:
+ if manual:
+ self.version_button.setText("已是最新")
+ self.version_label.setText("v%s · 最新" % APP_VERSION)
+ QTimer.singleShot(3200, self._reset_version_text)
+
+ def err(error):
+ self._checking_update = False
+ if manual:
+ self.version_button.setText("检查失败")
+ self.version_label.setText("v%s" % APP_VERSION)
+ QTimer.singleShot(3200, self._reset_version_text)
+ run_async(updater.fetch_update_info, ok, err)
+
+ def _reset_version_text(self):
+ self.version_button.setText("检查更新")
+ self.version_label.setText("v%s" % APP_VERSION)
+
+ def _show_update(self, info):
+ self.update_dialog = updater.UpdateDialog(info, self, self._on_update_state)
+ self.update_dialog.show()
+ self.update_dialog.raise_()
+
+ def _on_update_state(self, state, text):
+ if state == "ready":
+ self.version_label.setText("v%s · 更新就绪" % APP_VERSION)
+ self.version_button.setText("立即更新")
+ self.version_button.setProperty("ready", "true")
+ elif state == "downloading":
+ self.version_button.setText("下载中")
+ elif state == "error":
+ self.version_button.setText("重试更新")
+ self.version_button.style().unpolish(self.version_button)
+ self.version_button.style().polish(self.version_button)
+
+ def _open_chart(self, pid, name):
+ self.view_token += 1
+ token = self.view_token
+ self.page_eyebrow.setText("CHARTS / OFFICIAL")
+ self.page_title.setText(name)
+ self.stack_context_name = name
+ self.btn_back.setVisible(True)
+ self.btn_refresh.setVisible(False)
+ self.btn_play_all.setVisible(False)
+
+ cached = self.chart_cache.get(pid)
+ cached_songs = ((cached or {}).get("tracks") or [])[:100]
+ if cached_songs:
+ # 首页已经拿到完整数据,直接秒开,不再重复请求接口
+ self._render_chart_songs(cached, cached_songs)
+ return
+
+ self.page_meta.setText("正在加载…")
+ self._show_empty("正在加载榜单", "很快就能听到最新热门歌曲", busy=True)
+
+ # 首页请求尚未返回时直接等待同一份数据,避免再发一次重复请求
+ if pid in self.chart_inflight:
+ def waiter(playlist, error):
+ if token != self.view_token:
+ return
+ if error or not playlist:
+ self.page_meta.setText("榜单加载失败")
+ self._show_empty(
+ "这次没有加载成功",
+ str(error or "榜单数据为空"),
+ retry=lambda: self._open_chart(pid, name),
+ )
+ return
+ songs = (playlist.get("tracks") or [])[:100]
+ if songs:
+ self.chart_cache[pid] = playlist
+ self._render_chart_songs(playlist, songs)
+ else:
+ self.chart_cache.pop(pid, None)
+ QTimer.singleShot(0, lambda: self._open_chart(pid, name))
+ self.chart_waiters.setdefault(pid, []).append(waiter)
+ return
+
+ def ok(data):
+ if token != self.view_token:
+ return
+ playlist, songs = data
+ self.chart_cache[pid] = playlist
+ self._render_chart_songs(playlist, songs)
+
+ def err(error):
+ if token == self.view_token:
+ self.page_meta.setText("榜单加载失败")
+ self._show_empty(
+ "这次没有加载成功",
+ "%s\n点击下方按钮重新获取,不需要重启应用。" % error,
+ retry=lambda: self._open_chart(pid, name),
+ )
+ run_async(lambda: core.playlist_songs(pid, 100), ok, err)
+
+ def _open_import_dialog(self):
+ dialog = ImportDialog(self)
+ if dialog.exec() == QDialog.Accepted and dialog.value:
+ self._import_playlist(dialog.value)
+
+ def _import_playlist(self, value):
+ self.view_token += 1
+ token = self.view_token
+ self.page_eyebrow.setText("PLAYLIST / IMPORT")
+ self.page_title.setText("导入歌单")
+ self.page_meta.setText("正在读取歌单…")
+ self.btn_back.setVisible(True)
+ self.btn_refresh.setVisible(False)
+ self.btn_play_all.setVisible(False)
+ self._show_empty("正在读取歌单", "正在获取歌单信息和歌曲列表", busy=True)
+
+ def load():
+ pid = core.parse_playlist_ref(value)
+ playlist, songs = core.playlist_songs(pid, 1000)
+ return playlist, songs
+
+ def ok(data):
+ if token != self.view_token:
+ return
+ playlist, songs = data
+ if not songs:
+ self.page_meta.setText("歌单为空")
+ self._show_empty(
+ "这个歌单没有可播放歌曲",
+ "可能是私密歌单、已失效,或区域版权受限。",
+ retry=lambda: self._import_playlist(value),
+ )
+ return
+ self.page_title.setText(playlist.get("name") or "导入歌单")
+ self.stack_context_name = playlist.get("name") or "导入歌单"
+ total = playlist.get("trackCount") or len(songs)
+ extra = " · 当前导入前 %d 首" % len(songs) if total > len(songs) else ""
+ self.page_meta.setText("共 %d 首%s · 来自网易云音乐" % (total, extra))
+ self.table.set_songs(songs)
+ self.table.set_playing(self.current["id"] if self.current else None)
+ self._load_table_covers(songs)
+ self.stack_songs = songs
+ self.stack.setCurrentWidget(self.table)
+ self.btn_play_all.setVisible(True)
+
+ def err(error):
+ if token != self.view_token:
+ return
+ self.page_meta.setText("导入失败")
+ self._show_empty(
+ "没有导入成功",
+ "%s\n请确认歌单是公开的,或换一个链接再试。" % error,
+ retry=lambda: self._import_playlist(value),
+ )
+ run_async(load, ok, err)
+
+ def _render_chart_songs(self, playlist, songs):
+ self.page_meta.setText("%d 首 · 更新于 %s" % (
+ len(songs), self._fmt_date(playlist.get("updateTime"))))
+ self.table.set_songs(songs)
+ self.table.set_playing(self.current["id"] if self.current else None)
+ self._load_table_covers(songs)
+ self.stack_songs = songs
+ self.stack.setCurrentWidget(self.table)
+ self.btn_play_all.setVisible(True)
+
+ @staticmethod
+ def _fmt_date(timestamp):
+ try:
+ return datetime.datetime.fromtimestamp((timestamp or 0) / 1000).strftime("%Y-%m-%d")
+ except Exception:
+ return "--"
+
+ def _do_search(self):
+ keyword = self.search_input.text().strip()
+ if not keyword:
+ return
+ self.view_token += 1
+ token = self.view_token
+ self.page_meta.setText("正在搜索「%s」…" % keyword)
+ self.stack_context_name = "搜索 · %s" % keyword
+ self.btn_play_all.setVisible(False)
+ self._show_empty("正在搜索", "在整个曲库中寻找「%s」" % keyword, busy=True)
+
+ def ok(songs):
+ if token != self.view_token:
+ return
+ if not songs:
+ self.page_meta.setText("「%s」· 无结果" % keyword)
+ self._show_empty("没有找到相关歌曲", "试试更短的关键词或歌手名称")
+ return
+ self.page_meta.setText("「%s」 · %d 条结果" % (keyword, len(songs)))
+ self.table.set_songs(songs)
+ self.table.set_playing(self.current["id"] if self.current else None)
+ self._load_table_covers(songs)
+ self.stack_songs = songs
+ self.stack.setCurrentWidget(self.table)
+ self.btn_play_all.setVisible(True)
+
+ def err(error):
+ if token == self.view_token:
+ self.page_meta.setText("搜索失败")
+ self._show_empty("搜索没有完成", error, retry=self._do_search)
+ run_async(lambda: core.search_songs(keyword), ok, err)
+
+ # ---------- 队列 ----------
+ def _render_queue(self):
+ self.queue_list.clear()
+ for i, song in enumerate(self.queue):
+ item = QListWidgetItem("%s\n%s" % (song.get("name", ""), core.artists_of(song)))
+ item.setData(Qt.UserRole, i)
+ item.setToolTip(song.get("name", ""))
+ if i == self.q_index:
+ item.setForeground(QColor("#f27770"))
+ font = item.font()
+ font.setBold(True)
+ item.setFont(font)
+ self.queue_list.addItem(item)
+ self.queue_empty.setVisible(not self.queue)
+ if 0 <= self.q_index < self.queue_list.count():
+ self.queue_list.scrollToItem(self.queue_list.item(self.q_index))
+
+ def _on_queue_click(self, item):
+ index = item.data(Qt.UserRole)
+ if index is not None and 0 <= int(index) < len(self.queue):
+ self._play_index(int(index))
+
+ def _clear_queue(self):
+ self.queue = []
+ self.q_index = -1
+ self._render_queue()
+
+ # ---------- 播放 ----------
+ def _play_from_list(self, songs, index):
+ self.queue = list(songs)
+ self._set_current_playlist(self.stack_context_name)
+ self._play_index(index)
+
+ def _play_all(self):
+ if self.stack_songs:
+ self.queue = list(self.stack_songs)
+ self._set_current_playlist(self.stack_context_name)
+ self._play_index(0)
+
+ def _play_index(self, index):
+ if not (0 <= index < len(self.queue)):
+ return
+ self.q_index = index
+ self.current = self.queue[index]
+ song = self.current
+ level = self.quality
+ self._render_queue()
+ self._update_bar()
+ self._add_recent(song)
+ self.table.set_playing(song["id"])
+ self.bar_artist.setText("正在准备音频…")
+ self.bar_hint.setText(core.QUALITY_LABEL.get(level, ""))
+
+ def ok(data):
+ url, _ = data
+ if self.current is not song:
+ return
+ self.player.setSource(QUrl(url))
+ self.player.play()
+ self.bar_artist.setText(core.artists_of(song))
+ self._load_lyric(song)
+
+ def err(error):
+ if self.current is song:
+ self.bar_artist.setText("播放失败,请尝试下一首")
+ self.bar_hint.setText(error[:80])
+ run_async(lambda: core.song_play_url(song["id"], level), ok, err)
+
+ def _toggle_play(self):
+ if self.player.source().isEmpty():
+ if self.queue:
+ self._play_index(max(0, self.q_index))
+ return
+ if self.player.playbackState() == QMediaPlayer.PlayingState:
+ self.player.pause()
+ else:
+ self.player.play()
+
+ def _next(self):
+ if not self.queue:
+ return
+ if self.mode == "shuffle":
+ index = self.q_index
+ if len(self.queue) > 1:
+ while index == self.q_index:
+ index = random.randrange(len(self.queue))
+ self._play_index(index)
+ else:
+ self._play_index((self.q_index + 1) % len(self.queue))
+
+ def _prev(self):
+ if not self.queue:
+ return
+ if self.player.position() > 3000:
+ self.player.setPosition(0)
+ else:
+ self._play_index((self.q_index - 1) % len(self.queue))
+
+ def _cycle_mode(self):
+ self.mode = {"list": "one", "one": "shuffle", "shuffle": "list"}[self.mode]
+ icon = {"list": "repeat", "one": "single", "shuffle": "shuffle"}[self.mode]
+ label = {"list": "列表循环", "one": "单曲循环", "shuffle": "随机播放"}[self.mode]
+ self.btn_mode.setIcon(make_icon(icon, "#5f5b55" if self.mode == "list" else ACCENT, 17))
+ self.btn_mode.setToolTip(label)
+
+ def _on_state(self, state):
+ playing = state == QMediaPlayer.PlayingState
+ self.btn_play.setIcon(make_icon("pause" if playing else "play", "#ffffff", 18))
+ self.fs_play.setIcon(make_icon("pause" if playing else "play", "#242322", 20))
+ self.fs_disc.set_playing(playing)
+
+ def _on_media_error(self, *_):
+ if self.current and not self.player.source().isEmpty():
+ self.bar_artist.setText("音频加载失败,可切换音质或下一首")
+
+ def _on_position(self, position):
+ if not self.progress.isSliderDown() and not self.fs_progress.isSliderDown():
+ duration = max(1, self.player.duration() or 1)
+ value = int(position * 1000 / duration)
+ self.progress.setValue(value)
+ self.fs_progress.setValue(value)
+ self.t_now.setText(core.fmt_ms(position))
+ self.fs_now.setText(core.fmt_ms(position))
+
+ def _on_duration(self, duration):
+ if duration > 0:
+ self.t_dur.setText(core.fmt_ms(duration))
+ self.fs_dur.setText(core.fmt_ms(duration))
+
+ def _seek_preview(self, value):
+ duration = self.player.duration()
+ if duration:
+ self.t_now.setText(core.fmt_ms(duration * value / 1000))
+ self.fs_now.setText(core.fmt_ms(duration * value / 1000))
+
+ def _seek_commit(self):
+ duration = self.player.duration()
+ if not duration:
+ return
+ source = self.fs_progress if self.fs.isVisible() else self.progress
+ self.player.setPosition(int(duration * source.value() / 1000))
+ self._sync_lyric(force=True)
+
+ def _on_volume(self, value):
+ if self._syncing_volume:
+ return
+ self._syncing_volume = True
+ self.volume = int(value)
+ self.audio_out.setVolume(self.volume / 100)
+ self.vol_slider.setValue(self.volume)
+ self.fs_vol.setValue(self.volume)
+ icon = "volume" if self.volume else "volume-mute"
+ self.btn_mute.setIcon(make_icon(icon, "#5f5b55", 17))
+ self.fs_mute.setIcon(make_icon(icon, "#5f5b55", 18))
+ self._syncing_volume = False
+
+ def _toggle_mute(self):
+ if self.volume > 0:
+ self._prev_volume = self.volume
+ self.vol_slider.setValue(0)
+ else:
+ self.vol_slider.setValue(getattr(self, "_prev_volume", 70))
+
+ def _on_quality_changed(self, index):
+ if self._syncing_quality:
+ return
+ key = self.sender().itemData(index)
+ if not key or key == self.quality:
+ return
+ self.quality = key
+ self._syncing_quality = True
+ self.quality_box.setCurrentIndex(index)
+ self.fs_quality.setCurrentIndex(index)
+ self._syncing_quality = False
+ self.bar_hint.setText(core.QUALITY_LABEL.get(key, ""))
+ if self.current:
+ self._reload_current()
+
+ def _reload_current(self):
+ song = self.current
+ if not song:
+ return
+ position = self.player.position()
+ was_playing = self.player.playbackState() == QMediaPlayer.PlayingState
+ self.bar_artist.setText("正在切换音质…")
+
+ def ok(data):
+ url, _ = data
+ if self.current is not song:
+ return
+ self.player.setSource(QUrl(url))
+ if was_playing:
+ self.player.play()
+ QTimer.singleShot(400, lambda: self.player.setPosition(position))
+ self.bar_artist.setText(core.artists_of(song))
+ run_async(lambda: core.song_play_url(song["id"], self.quality), ok,
+ lambda error: self.bar_artist.setText("音质切换失败"))
+
+ # ---------- 当前歌曲与封面 ----------
+ def _update_bar_playlist(self):
+ label = self.current_playlist_name or "未播放"
+ raw = "当前歌单:" + label
+ width = max(80, self.bar_playlist.width() - 2)
+ shown = self.bar_playlist.fontMetrics().elidedText(
+ "歌单 · " + label, Qt.ElideRight, width)
+ self.bar_playlist.setText(shown)
+ self.bar_playlist.setToolTip(raw)
+
+ def _set_current_playlist(self, name):
+ self.current_playlist_name = (name or "").strip() or "未命名歌单"
+ self._update_bar_playlist()
+
+ def _update_bar(self):
+ song = self.current
+ if not song:
+ return
+ self.bar_name.setText(song.get("name", ""))
+ self.bar_artist.setText(core.artists_of(song))
+ self.bar_hint.setText(core.QUALITY_LABEL.get(self.quality, ""))
+ self.fs_name.setText(song.get("name", ""))
+ album = core.album_of(song)
+ self.fs_artist.setText(core.artists_of(song) + ((" · " + album) if album else ""))
+ self.lyric_view.set_lines([])
+ self.lyric_lines = []
+ self.lyric_index = -1
+ url = core.cover_of(song, 500)
+ run_async(lambda: fetch_bytes(url), lambda raw: self._set_artwork(song, raw), lambda error: None)
+
+ def _set_artwork(self, song, raw):
+ if self.current is not song or not raw:
+ return
+ pixmap = QPixmap()
+ if not pixmap.loadFromData(raw):
+ return
+ self.bar_cover.setPixmap(rounded_pixmap(pixmap, 56, 10))
+ self.fs_disc.set_cover(pixmap)
+ self.fs_bg.set_cover(pixmap)
+
+ # ---------- 歌词 ----------
+ def _load_lyric(self, song):
+ cached = self.lyric_cache.get(song["id"])
+ if cached is not None:
+ self._render_lyric(cached)
+ return
+
+ def ok(lines):
+ self.lyric_cache[song["id"]] = lines
+ if self.current and self.current["id"] == song["id"]:
+ self._render_lyric(lines)
+ run_async(lambda: core.fetch_lyric(song["id"]), ok, lambda error: self._render_lyric([]))
+
+ def _render_lyric(self, lines):
+ self.lyric_lines = lines or []
+ self.lyric_index = -1
+ self.lyric_view.set_lines(self.lyric_lines)
+ self._sync_lyric(force=True)
+
+ def _sync_lyric(self, force=False):
+ if not self.lyric_lines:
+ return
+ self.lyric_view.set_position(self.player.position() / 1000.0, force=force)
+ self.lyric_index = self.lyric_view.current_index
+
+ def _lyric_seek(self, milliseconds):
+ self.player.setPosition(int(milliseconds))
+ self._sync_lyric(force=True)
+
+ # ---------- 全屏 ----------
+ def _toggle_fullscreen(self):
+ if self.fs.isVisible():
+ self.fs.hide()
+ else:
+ self.fs.setGeometry(0, 0, self.width(), self.height())
+ self.fs_bg.setGeometry(self.fs.rect())
+ self.fs.show()
+ self.fs.raise_()
+ self.fs_bg.lower()
+ self.fs_disc.set_playing(self.player.playbackState() == QMediaPlayer.PlayingState)
+ QTimer.singleShot(0, lambda: self.lyric_view.center_current(False))
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ if hasattr(self, "fs") and self.fs.isVisible():
+ self.fs.setGeometry(0, 0, self.width(), self.height())
+ self.fs_bg.setGeometry(self.fs.rect())
+
+ # ---------- 最近播放 ----------
+ def _recent_path(self):
+ return os.path.join(os.path.expanduser("~"), ".netease-player-recent.json")
+
+ def _load_recent(self):
+ try:
+ with open(self._recent_path(), "r", encoding="utf-8") as handle:
+ data = json.load(handle)
+ return data if isinstance(data, list) else []
+ except Exception:
+ return []
+
+ def _add_recent(self, song):
+ self.recent = [item for item in self.recent if item.get("id") != song.get("id")]
+ self.recent.insert(0, song)
+ self.recent = self.recent[:50]
+ try:
+ with open(self._recent_path(), "w", encoding="utf-8") as handle:
+ json.dump(self.recent, handle, ensure_ascii=False)
+ except Exception:
+ pass
+
+ # ---------- 快捷键 ----------
+ def keyPressEvent(self, event):
+ key = event.key()
+ if key == Qt.Key_Space:
+ self._toggle_play()
+ elif key == Qt.Key_Right:
+ self.player.setPosition(min(self.player.duration(), self.player.position() + 5000))
+ elif key == Qt.Key_Left:
+ self.player.setPosition(max(0, self.player.position() - 5000))
+ elif key == Qt.Key_Up:
+ self.vol_slider.setValue(min(100, self.volume + 5))
+ elif key == Qt.Key_Down:
+ self.vol_slider.setValue(max(0, self.volume - 5))
+ elif key == Qt.Key_I and event.modifiers() & Qt.ControlModifier:
+ self._open_import_dialog()
+ elif key == Qt.Key_L:
+ self._toggle_fullscreen()
+ elif key == Qt.Key_Escape:
+ self.fs.hide()
+ else:
+ super().keyPressEvent(event)
+
+
+def main():
+ app = QApplication(sys.argv)
+ app.setApplicationName("网易云播放器")
+ app.setStyleSheet(QSS)
+ window = MainWindow()
+ window.show()
+ shot = os.environ.get("PLAYER_SHOT")
+ if shot:
+ def grab():
+ window.grab().save(shot)
+ app.quit()
+ QTimer.singleShot(int(os.environ.get("PLAYER_SHOT_DELAY", "8000")), grab)
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/theme.py b/src/theme.py
new file mode 100644
index 0000000..cee0e83
--- /dev/null
+++ b/src/theme.py
@@ -0,0 +1,182 @@
+"""视觉系统:深灰导航、暖白内容、克制的品牌红。"""
+
+ACCENT = "#e6493f"
+INK = "#242322"
+MUTED = "#8b8882"
+PAPER = "#f7f6f3"
+
+QSS = """
+* {
+ font-family: "Segoe UI Variable Text", "Microsoft YaHei UI", "PingFang SC", sans-serif;
+ color: #242322;
+ font-size: 13px;
+}
+QMainWindow, #root { background: #f7f6f3; }
+QToolTip {
+ background: #25262a; color: #f7f6f3; border: none; border-radius: 5px;
+ padding: 5px 8px; font-size: 11px;
+}
+
+/* 左侧导航 */
+#side { background: #1e1f23; }
+#brandName { color: #f7f6f3; font-size: 14px; font-weight: 650; }
+#brandSub { color: #777981; font-size: 9px; letter-spacing: 1.4px; }
+#sideSection { color: #70727a; font-size: 10px; font-weight: 600; }
+QPushButton#nav {
+ color: #a9abb2; background: transparent; border: none; border-radius: 8px;
+ text-align: left; padding-left: 12px; min-height: 40px; max-height: 40px;
+ font-size: 13px;
+}
+QPushButton#nav:hover { color: #f3f3f4; background: #28292e; }
+QPushButton#nav[active="true"] { color: #ffffff; background: #323338; font-weight: 600; }
+QPushButton#clearBtn { color: #777981; border: none; background: transparent; font-size: 11px; }
+QPushButton#clearBtn:hover { color: #f0f0f1; }
+QListWidget#queue { background: transparent; border: none; outline: none; }
+QListWidget#queue::item {
+ color: #9698a0; border: none; border-radius: 7px; padding: 0 8px; height: 44px;
+}
+QListWidget#queue::item:hover { background: #28292e; color: #e7e7e9; }
+QListWidget#queue::item:selected { background: #303137; color: #f3f3f4; }
+#queueEmpty { color: #62646c; font-size: 11px; }
+#versionLabel { color: #666871; font-size: 9.5px; letter-spacing: .4px; }
+QPushButton#versionBtn {
+ color: #8b8d95; background: transparent; border: none; border-radius: 5px;
+ padding: 0 5px; min-height: 24px; font-size: 10px;
+}
+QPushButton#versionBtn:hover { color: #f0f0f1; background: #2a2b30; }
+QPushButton#versionBtn[ready="true"] { color: #f27770; font-weight: 600; }
+
+/* 主内容 */
+#main { background: #f7f6f3; }
+#pageEyebrow { color: #a29e97; font-size: 10px; font-weight: 600; letter-spacing: 1.6px; }
+#pageTitle { color: #242322; font-size: 27px; font-weight: 700; letter-spacing: -0.6px; }
+#pageMeta { color: #8b8882; font-size: 12px; }
+QPushButton#textBtn {
+ color: #625f5a; background: transparent; border: 1px solid #dfdcd6;
+ border-radius: 8px; min-height: 32px; max-height: 32px; padding: 0 13px; font-size: 12px;
+}
+QPushButton#textBtn:hover { color: #242322; background: #fffefa; border-color: #cfcbc4; }
+QPushButton#accent {
+ color: #ffffff; background: #e6493f; border: none; border-radius: 8px;
+ min-height: 34px; max-height: 34px; padding: 0 16px; font-size: 12px; font-weight: 600;
+}
+QPushButton#accent:hover { background: #d93e35; }
+QPushButton#iconBtn { background: transparent; border: none; border-radius: 8px; }
+QPushButton#iconBtn:hover { background: #e9e6e1; }
+QPushButton#iconBtn:pressed { background: #dfdcd6; }
+QPushButton#iconBtn[tone="dark"]:hover { background: #2c2d32; }
+QPushButton#playBtn { background: #e6493f; border: none; border-radius: 21px; }
+QPushButton#playBtn:hover { background: #d93e35; }
+QPushButton#playBtn:pressed { background: #c9372f; }
+
+/* 榜单:色块而不是泛白卡片 */
+QPushButton#chartCard {
+ background: #eeece8; border: 1px solid transparent; border-radius: 14px; text-align: left;
+}
+QPushButton#chartCard:hover { background: #e8e4de; border-color: #ddd8d0; }
+QPushButton#chartCard:pressed { background: #e2ddd6; }
+#chartName { font-size: 17px; font-weight: 650; color: #2c2a27; }
+#chartMeta { font-size: 11px; color: #8d8880; }
+#chartIndex { font-size: 11px; color: #aaa49b; font-weight: 600; }
+
+/* 搜索 */
+QLineEdit#search {
+ min-height: 40px; max-height: 40px; background: #fffefa; color: #242322;
+ border: 1px solid #e1ded8; border-radius: 10px; padding: 0 14px; font-size: 13px;
+ selection-background-color: #f3c5c1;
+}
+QLineEdit#search:focus { border-color: #bdb7ae; background: #ffffff; }
+QLineEdit#search::placeholder { color: #aaa69f; }
+
+/* 歌曲表格 */
+QTableWidget {
+ background: transparent; border: none; outline: none; gridline-color: transparent;
+ selection-background-color: #ebe8e3; selection-color: #242322; font-size: 13px;
+}
+QTableWidget::item { border: none; border-bottom: 1px solid #ece9e4; padding-left: 6px; }
+QHeaderView::section {
+ background: #f7f6f3; color: #9b978f; border: none; border-bottom: 1px solid #e4e0da;
+ font-size: 10px; font-weight: 600; height: 32px; padding-left: 8px;
+}
+#emptyTitle { color: #5e5a54; font-size: 16px; font-weight: 600; }
+#emptyMeta { color: #9b978f; font-size: 12px; }
+QProgressBar#emptyProgress {
+ border: none; border-radius: 2px; background: #e9e5df;
+}
+QProgressBar#emptyProgress::chunk { background: #e6493f; border-radius: 2px; }
+
+/* 底部播放栏 */
+#bar { background: #fffefa; border-top: 1px solid #e7e3dd; }
+#barName { font-size: 13px; font-weight: 650; color: #2c2a27; }
+#barArtist { font-size: 11px; color: #918d85; }
+#barHint { color: #b0aba3; font-size: 10px; }
+#barPlaylist {
+ color: #aaa49b; font-size: 9.5px; letter-spacing: .02em;
+ background: transparent;
+}
+#timeLabel { color: #9b978f; font-size: 10px; font-family: "Segoe UI", sans-serif; }
+QSlider::groove:horizontal { height: 4px; background: #e8e4de; border-radius: 2px; }
+QSlider::sub-page:horizontal { height: 4px; background: #e6493f; border-radius: 2px; }
+QSlider::handle:horizontal {
+ width: 12px; height: 12px; margin: -4px 0; border-radius: 6px;
+ background: #fffefa; border: 2px solid #e6493f;
+}
+QSlider::handle:horizontal:hover { background: #e6493f; }
+QSlider#vol::groove:horizontal { height: 3px; }
+QSlider#vol::sub-page:horizontal { background: #77736c; height: 3px; }
+QSlider#vol::handle:horizontal { width: 10px; height: 10px; border-radius: 5px; border: 1px solid #8b8780; }
+QComboBox {
+ min-height: 28px; max-height: 28px; border: 1px solid #e0dcd5; border-radius: 7px;
+ background: transparent; color: #6d6962; padding: 0 8px; font-size: 11px;
+}
+QComboBox:hover { background: #f3f0eb; border-color: #cec9c0; }
+QComboBox::drop-down { border: none; width: 16px; }
+QComboBox QAbstractItemView {
+ background: #fffefa; border: 1px solid #dfdcd6; outline: none; padding: 4px;
+ selection-background-color: #eeeae4; selection-color: #242322;
+}
+
+/* 导入歌单 */
+#importCard { background:#fffefa; border:1px solid #e4dfd8; border-radius:16px; }
+#importTitle { color:#22211f; font-size:19px; font-weight:700; }
+#importSub { color:#8d8880; font-size:12px; }
+#importInput {
+ min-height:42px; max-height:42px; background:#f8f5f0; color:#242322;
+ border:1px solid #e1dcd4; border-radius:9px; padding:0 13px; font-size:12.5px;
+ selection-background-color:#f3c5c1;
+}
+#importInput:focus { background:#fffefa; border-color:#bdb6ab; }
+#importHint { color:#aaa49b; font-size:10.5px; }
+#importError { color:#c83b32; font-size:11px; }
+QPushButton#importCancel {
+ background:transparent; border:1px solid #e0dcd5; border-radius:8px;
+ min-height:36px; color:#6d6962; font-size:12px; padding:0 18px;
+}
+QPushButton#importCancel:hover { background:#f3f0eb; color:#242322; }
+QPushButton#importGo {
+ background:#e6493f; border:none; border-radius:8px; min-height:36px;
+ color:#fff; font-size:12px; font-weight:600; padding:0 20px;
+}
+QPushButton#importGo:hover { background:#d93e35; }
+QPushButton#importGo:disabled { background:#e8e4de; color:#aaa59c; }
+
+/* 全屏播放 */
+#fs { background: #f3f1ed; }
+#fsEyebrow { color: #a39e95; font-size: 10px; letter-spacing: 1.5px; font-weight: 600; }
+#fsName { color: #272521; font-size: 20px; font-weight: 700; letter-spacing: -0.3px; }
+#fsArtist { color: #8e8981; font-size: 12px; }
+#lyricViewport { background: transparent; border: none; }
+#LyricView, #lyricContent { background: transparent; border: none; }
+#fsControl { background: transparent; }
+
+/* 滚动条 */
+QScrollBar:vertical { background: transparent; width: 8px; margin: 2px; }
+QScrollBar::handle:vertical { background: #d5d0c8; border-radius: 4px; min-height: 36px; }
+QScrollBar::handle:vertical:hover { background: #bdb7ae; }
+QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
+QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
+QScrollBar:horizontal { height: 8px; background: transparent; }
+QScrollBar::handle:horizontal { background: #d5d0c8; border-radius: 4px; }
+QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
+#side QScrollBar::handle:vertical { background: #424349; }
+"""
diff --git a/src/updater.py b/src/updater.py
new file mode 100644
index 0000000..1eefb2d
--- /dev/null
+++ b/src/updater.py
@@ -0,0 +1,400 @@
+"""版本检查、自动下载与安装。"""
+import hashlib
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import urllib.parse
+
+import requests
+from PySide6.QtCore import Qt, QUrl, QTimer
+from PySide6.QtGui import QColor, QDesktopServices
+from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
+from PySide6.QtWidgets import (
+ QApplication, QDialog, QFrame, QHBoxLayout, QLabel, QProgressBar, QPushButton,
+ QVBoxLayout, QWidget, QGraphicsDropShadowEffect,
+)
+
+from version import APP_VERSION, UPDATE_ENDPOINT
+from widgets import make_icon, icon_button
+
+
+UPDATE_QSS = """
+#updateCard { background:#fffefa; border:1px solid #e6e1da; border-radius:18px; }
+QPushButton#iconBtn { background:transparent; border:none; border-radius:7px; outline:none; }
+QPushButton#iconBtn:hover { background:#f2efea; }
+QPushButton:focus { outline:none; }
+#updateEyebrow { color:#e6493f; font-size:9px; font-weight:700; letter-spacing:1.7px; }
+#updateTitle { color:#22211f; font-size:20px; font-weight:700; }
+#updateSub { color:#8c877f; font-size:12px; }
+#updateMeta { color:#aaa59c; font-size:10.5px; }
+#updateNotesTitle { color:#8c877f; font-size:10px; font-weight:600; letter-spacing:1px; }
+#updateNote { color:#5f5b55; font-size:12px; }
+#updateStatus { color:#8c877f; font-size:11px; }
+QProgressBar#updateProgress {
+ border:none; border-radius:3px; background:#eeeae4; height:6px; text-align:center;
+ color:transparent;
+}
+QProgressBar#updateProgress::chunk { background:#e6493f; border-radius:3px; }
+QPushButton#updateLater {
+ background:transparent; border:1px solid #e0dcd5; border-radius:8px;
+ min-height:36px; color:#6d6962; font-size:12px; padding:0 18px;
+}
+QPushButton#updateLater:hover { background:#f3f0eb; color:#242322; }
+QPushButton#updatePrimary {
+ background:#e6493f; border:none; border-radius:8px; min-height:36px;
+ color:#ffffff; font-size:12px; font-weight:600; padding:0 20px;
+}
+QPushButton#updatePrimary:hover { background:#d93e35; }
+QPushButton#updatePrimary:disabled { background:#e8e4de; color:#aaa59c; }
+QPushButton#updateLink {
+ background:transparent; border:none; color:#9b968e; font-size:10.5px;
+ text-align:left; padding:0;
+}
+QPushButton#updateLink:hover { color:#625f5a; }
+"""
+
+
+def parse_version(value):
+ text = str(value or "").strip()
+ text = re.split(r"[-+]", text, maxsplit=1)[0]
+ return tuple(int(part) for part in re.findall(r"\d+", text))
+
+
+def is_newer(latest, current=APP_VERSION):
+ left, right = parse_version(latest), parse_version(current)
+ if not left:
+ return False
+ size = max(len(left), len(right))
+ return left + (0,) * (size - len(left)) > right + (0,) * (size - len(right))
+
+
+def fetch_update_info(endpoint=UPDATE_ENDPOINT):
+ response = requests.get(
+ endpoint,
+ timeout=10,
+ headers={"User-Agent": "NeteasePlayer/%s" % APP_VERSION, "Cache-Control": "no-cache"},
+ )
+ response.raise_for_status()
+ info = response.json()
+ if not isinstance(info, dict):
+ raise RuntimeError("更新信息格式错误")
+ if not info.get("version") or not info.get("download_url"):
+ raise RuntimeError("更新信息缺少必要字段")
+ return info
+
+
+def human_size(size):
+ try:
+ size = float(size)
+ except (TypeError, ValueError):
+ return ""
+ if size >= 1024 ** 3:
+ return "%.2f GB" % (size / 1024 ** 3)
+ if size >= 1024 ** 2:
+ return "%.1f MB" % (size / 1024 ** 2)
+ return "%.0f KB" % (size / 1024)
+
+
+def _safe_suffix(url):
+ suffix = os.path.splitext(urllib.parse.urlparse(url).path)[1].lower()
+ return suffix if suffix in (".exe", ".msi", ".zip") else ".exe"
+
+
+def _offset_dir(version):
+ base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir()
+ path = os.path.join(base, "NeteasePlayer", "updates")
+ os.makedirs(path, exist_ok=True)
+ return path
+
+
+def _quote(path):
+ return '"' + str(path).replace('"', '""') + '"'
+
+
+class UpdateDialog(QDialog):
+ """发现新版本后立即自动下载,下载完成可一键重启安装。"""
+
+ def __init__(self, info, parent=None, on_state=None):
+ super().__init__(parent)
+ self.info = info
+ self.on_state = on_state
+ self.reply = None
+ self.file = None
+ self.ready_path = None
+ self.downloaded = 0
+ self.total = int(info.get("size") or 0)
+ self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint)
+ self.setAttribute(Qt.WA_TranslucentBackground)
+ self.setModal(True)
+ self.setStyleSheet(UPDATE_QSS)
+ self._build_ui()
+ QTimer.singleShot(300, self.start_download)
+
+ def _build_ui(self):
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(18, 18, 18, 18)
+ card = QFrame(objectName="updateCard")
+ shadow = QGraphicsDropShadowEffect(card)
+ shadow.setBlurRadius(38)
+ shadow.setOffset(0, 12)
+ shadow.setColor(QColor(28, 27, 31, 65))
+ card.setGraphicsEffect(shadow)
+ outer.addWidget(card)
+
+ layout = QVBoxLayout(card)
+ layout.setContentsMargins(26, 22, 26, 22)
+ layout.setSpacing(0)
+
+ top = QHBoxLayout()
+ top.setSpacing(13)
+ icon_box = QLabel()
+ icon_box.setFixedSize(42, 42)
+ icon_box.setPixmap(make_icon("refresh", "#ffffff", 20).pixmap(42, 42))
+ icon_box.setStyleSheet("background:#e6493f; border-radius:12px;")
+ icon_box.setAlignment(Qt.AlignCenter)
+ top.addWidget(icon_box)
+ titles = QVBoxLayout()
+ titles.setSpacing(2)
+ titles.addWidget(QLabel("NEW VERSION", objectName="updateEyebrow"))
+ self.title = QLabel("发现新版本", objectName="updateTitle")
+ titles.addWidget(self.title)
+ top.addLayout(titles, 1)
+ close = icon_button("close", 32, 15, tooltip="稍后再说")
+ close.clicked.connect(self.hide)
+ top.addWidget(close)
+ layout.addLayout(top)
+ layout.addSpacing(16)
+
+ latest = self.info.get("version", "")
+ self.sub = QLabel("正在为你自动下载新版本", objectName="updateSub")
+ layout.addWidget(self.sub)
+ notes = self.info.get("notes") or []
+ date = self.info.get("release_date") or ""
+ size = human_size(self.info.get("size"))
+ meta_parts = ["v%s → v%s" % (APP_VERSION, latest)]
+ if date:
+ meta_parts.append(date)
+ if size:
+ meta_parts.append(size)
+ self.meta = QLabel(" · ".join(meta_parts), objectName="updateMeta")
+ layout.addWidget(self.meta)
+ layout.addSpacing(16)
+
+ notes_title = QLabel("本次更新", objectName="updateNotesTitle")
+ layout.addWidget(notes_title)
+ layout.addSpacing(7)
+ box = QWidget()
+ box_layout = QVBoxLayout(box)
+ box_layout.setContentsMargins(0, 0, 0, 0)
+ box_layout.setSpacing(6)
+ if not notes:
+ notes = ["稳定性与性能优化"]
+ for note in notes[:6]:
+ row = QHBoxLayout()
+ row.setSpacing(8)
+ dot = QLabel("·")
+ dot.setStyleSheet("color:#e6493f; font-size:16px; font-weight:700;")
+ dot.setFixedWidth(8)
+ item = QLabel(str(note), objectName="updateNote")
+ item.setWordWrap(True)
+ row.addWidget(dot, 0, Qt.AlignTop)
+ row.addWidget(item, 1)
+ box_layout.addLayout(row)
+ layout.addWidget(box)
+ layout.addSpacing(18)
+
+ self.progress = QProgressBar(objectName="updateProgress")
+ self.progress.setRange(0, 100)
+ self.progress.setValue(0)
+ self.progress.setTextVisible(False)
+ layout.addWidget(self.progress)
+ layout.addSpacing(7)
+ self.status = QLabel("准备下载…", objectName="updateStatus")
+ layout.addWidget(self.status)
+ layout.addSpacing(16)
+
+ footer = QHBoxLayout()
+ footer.setSpacing(9)
+ self.link = QPushButton("查看下载页", objectName="updateLink")
+ self.link.setCursor(Qt.PointingHandCursor)
+ self.link.clicked.connect(self._open_page)
+ footer.addWidget(self.link)
+ footer.addStretch()
+ self.later = QPushButton("稍后再说", objectName="updateLater")
+ self.later.setCursor(Qt.PointingHandCursor)
+ self.later.clicked.connect(self.hide)
+ footer.addWidget(self.later)
+ self.primary = QPushButton("正在下载…", objectName="updatePrimary")
+ self.primary.setCursor(Qt.PointingHandCursor)
+ self.primary.setEnabled(False)
+ self.primary.clicked.connect(self._primary_action)
+ footer.addWidget(self.primary)
+ layout.addLayout(footer)
+
+ def _open_page(self):
+ QDesktopServices.openUrl(QUrl(self.info.get("page_url") or "https://music.yuncishu.xyz/download/"))
+
+ def start_download(self):
+ if self.ready_path:
+ return
+ if self.reply:
+ self.reply.abort()
+ self.reply.deleteLater()
+ self.reply = None
+ if self.file:
+ try:
+ self.file.close()
+ except Exception:
+ pass
+ self.file = None
+ url = self.info.get("download_url")
+ filename = "NeteasePlayer-%s%s" % (self.info.get("version", "new"), _safe_suffix(url))
+ self.ready_path = os.path.join(_offset_dir(self.info.get("version", "new")), filename)
+ self.file = open(self.ready_path + ".part", "wb")
+ self.downloaded = 0
+ self.progress.setValue(0)
+ self.status.setText("正在连接下载服务器…")
+ self.primary.setText("正在下载…")
+ self.primary.setEnabled(False)
+ self._notify("downloading", "正在下载")
+ self.manager = QNetworkAccessManager(self)
+ request = QNetworkRequest(QUrl(url))
+ request.setRawHeader(b"User-Agent", ("NeteasePlayer/%s" % APP_VERSION).encode())
+ try:
+ request.setAttribute(
+ QNetworkRequest.Attribute.RedirectPolicyAttribute,
+ QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy,
+ )
+ except Exception:
+ pass
+ self.reply = self.manager.get(request)
+ self.reply.readyRead.connect(self._read)
+ self.reply.downloadProgress.connect(self._progress)
+ self.reply.finished.connect(self._finished)
+
+ def _read(self):
+ if not self.reply or not self.file:
+ return
+ data = bytes(self.reply.readAll())
+ if data:
+ self.file.write(data)
+ self.downloaded += len(data)
+
+ def _progress(self, received, total):
+ if total > 0:
+ self.total = total
+ percent = int(received * 100 / total)
+ self.progress.setValue(max(0, min(100, percent)))
+ self.status.setText("正在下载 %s / %s" % (human_size(received), human_size(total)))
+ else:
+ self.status.setText("正在下载 %s" % human_size(received))
+
+ def _finished(self):
+ reply = self.reply
+ if not reply:
+ return
+ self._read()
+ if self.file:
+ self.file.flush()
+ self.file.close()
+ self.file = None
+ if reply.error() != QNetworkReply.NetworkError.NoError:
+ self.status.setText("下载失败 · " + reply.errorString())
+ self.primary.setText("重新下载")
+ self.primary.setEnabled(True)
+ self.primary.setProperty("action", "retry")
+ self._notify("error", "更新下载失败")
+ reply.deleteLater()
+ self.reply = None
+ return
+ part_path = self.ready_path + ".part"
+ expected = int(self.info.get("size") or 0)
+ if expected and os.path.getsize(part_path) != expected:
+ self.status.setText("文件大小校验失败,请重新下载")
+ self.primary.setText("重新下载")
+ self.primary.setEnabled(True)
+ self.primary.setProperty("action", "retry")
+ self._notify("error", "更新文件不完整")
+ reply.deleteLater()
+ self.reply = None
+ return
+ digest = self.info.get("sha256")
+ if digest:
+ hasher = hashlib.sha256()
+ with open(part_path, "rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ hasher.update(chunk)
+ if hasher.hexdigest().lower() != str(digest).lower():
+ self.status.setText("安全校验失败,已阻止安装")
+ self.primary.setText("重新下载")
+ self.primary.setEnabled(True)
+ self.primary.setProperty("action", "retry")
+ self._notify("error", "更新文件校验失败")
+ reply.deleteLater()
+ self.reply = None
+ return
+ os.replace(part_path, self.ready_path)
+ self.ready_path_final = self.ready_path
+ self.progress.setValue(100)
+ self.status.setText("下载完成 · 已通过完整性校验")
+ self.sub.setText("新版本已准备好,重启后即可使用")
+ self.primary.setText("重启并安装")
+ self.primary.setEnabled(True)
+ self.primary.setProperty("action", "install")
+ self._notify("ready", "更新已就绪")
+ reply.deleteLater()
+ self.reply = None
+
+ def _primary_action(self):
+ action = self.primary.property("action")
+ if action == "retry":
+ self.primary.setProperty("action", "")
+ self.start_download()
+ elif action == "install":
+ self.install()
+ else:
+ self.install()
+
+ def install(self):
+ path = getattr(self, "ready_path_final", None) or self.ready_path
+ if not path or not os.path.exists(path):
+ self.status.setText("安装包不存在,请重新下载")
+ return
+ if not getattr(sys, "frozen", False):
+ QDesktopServices.openUrl(QUrl.fromLocalFile(path))
+ self.status.setText("安装包已打开")
+ return
+ current = os.path.abspath(sys.executable)
+ script_path = os.path.join(tempfile.gettempdir(), "netease_player_update.cmd")
+ script = "\r\n".join([
+ "@echo off",
+ "chcp 65001 >nul",
+ "timeout /t 2 /nobreak >nul",
+ "taskkill /PID %d /F >nul 2>&1" % os.getpid(),
+ 'copy /Y %s %s >nul 2>&1' % (_quote(path), _quote(current)),
+ 'start "" %s' % _quote(current),
+ 'del "%~f0" >nul 2>&1',
+ "",
+ ])
+ with open(script_path, "w", encoding="utf-8") as handle:
+ handle.write(script)
+ subprocess.Popen(
+ ["cmd.exe", "/c", script_path],
+ creationflags=0x08000000 | 0x00000008,
+ close_fds=True,
+ )
+ QApplication.quit()
+
+ def _notify(self, state, text):
+ if self.on_state:
+ self.on_state(state, text)
+
+ def showEvent(self, event):
+ super().showEvent(event)
+ parent = self.parentWidget()
+ if parent:
+ x = parent.x() + (parent.width() - self.width()) // 2
+ y = parent.y() + (parent.height() - self.height()) // 2
+ self.move(max(0, x), max(0, y))
diff --git a/src/version.py b/src/version.py
new file mode 100644
index 0000000..6faeea0
--- /dev/null
+++ b/src/version.py
@@ -0,0 +1,7 @@
+"""应用版本与更新地址。"""
+
+APP_NAME = "网易云播放器"
+APP_VERSION = "1.2.2"
+BUILD_DATE = "2026-10-31"
+UPDATE_ENDPOINT = "https://music.yuncishu.xyz/api/app/update"
+DOWNLOAD_PAGE = "https://music.yuncishu.xyz/download/"
diff --git a/src/widgets.py b/src/widgets.py
new file mode 100644
index 0000000..07ef8f2
--- /dev/null
+++ b/src/widgets.py
@@ -0,0 +1,601 @@
+"""播放器专用原生组件:矢量图标、流畅黑胶、居中歌词、歌曲表格。"""
+import bisect
+
+from PySide6.QtCore import (
+ Qt, QSize, QRectF, QByteArray, QTimer, QElapsedTimer,
+ QPropertyAnimation, QEasingCurve, Signal,
+)
+from PySide6.QtGui import QPixmap, QIcon, QColor, QFont, QPainter, QPainterPath, QImage, QLinearGradient
+from PySide6.QtSvg import QSvgRenderer
+from PySide6.QtWidgets import (
+ QWidget, QLabel, QPushButton, QSlider, QStyle, QScrollArea, QVBoxLayout, QHBoxLayout,
+ QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QSizePolicy,
+ QGraphicsScene, QGraphicsBlurEffect,
+)
+
+import core
+
+
+_S = ''
+ICONS = {
+ "play": _S % '',
+ "pause": _S % '',
+ "prev": _S % '',
+ "next": _S % '',
+ "volume": _S % '',
+ "volume-mute": _S % '',
+ "lyric": _S % '',
+ "repeat": _S % '',
+ "single": _S % '',
+ "shuffle": _S % '',
+ "close": _S % '',
+ "refresh": _S % '',
+ "search": _S % '',
+ "chart": _S % '',
+ "clock": _S % '',
+ "back": _S % '',
+ "plus": _S % '',
+ "playlist": _S % '',
+ "expand": _S % '',
+ "music": _S % '',
+ "netease": _S % '',
+}
+
+
+def make_icon(name, color="#242322", size=20):
+ renderer = QSvgRenderer(QByteArray(ICONS[name].replace("COLOR", color).encode("utf-8")))
+ pm = QPixmap(size * 2, size * 2)
+ pm.fill(Qt.transparent)
+ painter = QPainter(pm)
+ painter.setRenderHint(QPainter.Antialiasing)
+ renderer.render(painter)
+ painter.end()
+ pm.setDevicePixelRatio(2)
+ return QIcon(pm)
+
+
+def _scaled_cover(pm, size):
+ if pm.isNull():
+ out = QPixmap(size, size)
+ out.fill(QColor("#dedbd5"))
+ return out
+ return pm.scaled(size, size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
+
+
+def rounded_pixmap(pm, size, radius=8):
+ scaled = _scaled_cover(pm, size)
+ out = QPixmap(size, size)
+ out.fill(Qt.transparent)
+ painter = QPainter(out)
+ painter.setRenderHint(QPainter.Antialiasing)
+ painter.setRenderHint(QPainter.SmoothPixmapTransform)
+ path = QPainterPath()
+ path.addRoundedRect(QRectF(0, 0, size, size), radius, radius)
+ painter.setClipPath(path)
+ painter.drawPixmap((size - scaled.width()) // 2, (size - scaled.height()) // 2, scaled)
+ painter.end()
+ return out
+
+
+def vinyl_pixmap(cover, size):
+ """预渲染整张黑胶;旋转时只做绘制,不再创建新图片。"""
+ out = QPixmap(size, size)
+ out.fill(Qt.transparent)
+ p = QPainter(out)
+ p.setRenderHint(QPainter.Antialiasing)
+ p.setRenderHint(QPainter.SmoothPixmapTransform)
+ center = size / 2
+ p.setPen(Qt.NoPen)
+ p.setBrush(QColor("#17181b"))
+ p.drawEllipse(QRectF(1, 1, size - 2, size - 2))
+ # 细密的唱片纹路
+ for ratio, color in ((0.96, "#24252a"), (0.88, "#111216"), (0.80, "#25262b"), (0.72, "#111216"), (0.64, "#24252a")):
+ p.setPen(QColor(color))
+ p.setBrush(Qt.NoBrush)
+ d = size * ratio
+ p.drawEllipse(QRectF(center - d / 2, center - d / 2, d, d))
+ cover_size = int(size * 0.57)
+ scaled = _scaled_cover(cover, cover_size)
+ path = QPainterPath()
+ x = (size - cover_size) / 2
+ path.addEllipse(QRectF(x, x, cover_size, cover_size))
+ p.setClipPath(path)
+ p.drawPixmap(int((size - scaled.width()) / 2), int((size - scaled.height()) / 2), scaled)
+ p.setClipping(False)
+ p.setPen(QColor("#34353a"))
+ p.setBrush(QColor("#111216"))
+ hole = max(8, int(size * 0.055))
+ p.drawEllipse(QRectF(center - hole / 2, center - hole / 2, hole, hole))
+ p.setPen(Qt.NoPen)
+ p.setBrush(QColor("#f3f1ed"))
+ dot = max(3, int(size * 0.018))
+ p.drawEllipse(QRectF(center - dot / 2, center - dot / 2, dot, dot))
+ p.end()
+ return out
+
+
+class CoverBackdrop(QWidget):
+ """网页端播放页同款:高斯模糊封面 + 白色纵向渐隐蒙层。"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.background = QPixmap()
+ self.setAttribute(Qt.WA_TransparentForMouseEvents)
+
+ def set_cover(self, cover):
+ if cover.isNull():
+ self.background = QPixmap()
+ self.update()
+ return
+ source = cover.scaled(720, 720, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
+ image = QImage(source.size(), QImage.Format_ARGB32_Premultiplied)
+ image.fill(Qt.white)
+ scene = QGraphicsScene()
+ item = scene.addPixmap(source)
+ blur = QGraphicsBlurEffect()
+ blur.setBlurRadius(52)
+ item.setGraphicsEffect(blur)
+ painter = QPainter(image)
+ scene.render(painter)
+ painter.end()
+ self.background = QPixmap.fromImage(image)
+ self.update()
+
+ def paintEvent(self, event):
+ painter = QPainter(self)
+ painter.fillRect(self.rect(), QColor("#ffffff"))
+ if not self.background.isNull():
+ scaled = self.background.scaled(self.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
+ painter.drawPixmap((self.width() - scaled.width()) // 2,
+ (self.height() - scaled.height()) // 2, scaled)
+ veil = QLinearGradient(0, 0, 0, max(1, self.height()))
+ veil.setColorAt(0.0, QColor(255, 255, 255, 148))
+ veil.setColorAt(0.42, QColor(255, 255, 255, 209))
+ veil.setColorAt(1.0, QColor(255, 255, 255, 244))
+ painter.fillRect(self.rect(), veil)
+
+
+class DiscWidget(QWidget):
+ """时间驱动的 60fps 黑胶,严格绕中心,不依赖定时器累计角度。"""
+ def __init__(self, size=300, parent=None):
+ super().__init__(parent)
+ self._size = size
+ self.setFixedSize(size, size)
+ self.angle = 0.0
+ self.playing = False
+ self.base = vinyl_pixmap(QPixmap(), size * 2)
+ self.clock = QElapsedTimer()
+ self.timer = QTimer(self)
+ self.timer.setTimerType(Qt.PreciseTimer)
+ self.timer.setInterval(16)
+ self.timer.timeout.connect(self._tick)
+
+ def set_cover(self, pm):
+ self.base = vinyl_pixmap(pm, self._size * 2)
+ self.angle = 0.0
+ self.update()
+
+ def set_playing(self, playing):
+ self.playing = bool(playing)
+ if self.playing and self.isVisible():
+ self.clock.start()
+ self.timer.start()
+ else:
+ self.timer.stop()
+
+ def _tick(self):
+ if not self.playing:
+ return
+ elapsed = self.clock.restart()
+ # 24 秒一圈;按真实耗时计算,掉帧也不会忽快忽慢
+ self.angle = (self.angle + min(elapsed, 80) * 360.0 / 24000.0) % 360.0
+ self.update()
+
+ def showEvent(self, event):
+ super().showEvent(event)
+ self.set_playing(self.playing)
+
+ def hideEvent(self, event):
+ self.timer.stop()
+ super().hideEvent(event)
+
+ def paintEvent(self, event):
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+ painter.setRenderHint(QPainter.SmoothPixmapTransform)
+ painter.translate(self.width() / 2, self.height() / 2)
+ painter.rotate(self.angle)
+ painter.translate(-self.width() / 2, -self.height() / 2)
+ painter.drawPixmap(0, 0, self.width(), self.height(), self.base)
+
+
+class ClickSlider(QSlider):
+ def __init__(self, orientation, parent=None):
+ super().__init__(orientation, parent)
+ self.setCursor(Qt.PointingHandCursor)
+
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton and self.maximum() > self.minimum():
+ pos = int(event.position().x())
+ value = QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), pos, self.width())
+ self.setValue(value)
+ self.sliderMoved.emit(value)
+ self.sliderReleased.emit()
+ event.accept()
+ return
+ super().mousePressEvent(event)
+
+
+class LyricRow(QWidget):
+ """网页端歌词行:主歌词与翻译分别排版,当前行只放大、不变行高。"""
+ clicked = Signal(int)
+
+ def __init__(self, index, text, translation, height, parent=None):
+ super().__init__(parent)
+ self.index = index
+ self.main_text = text
+ self.translation = translation
+ self.current = False
+ self.setFixedHeight(height)
+ self.setCursor(Qt.PointingHandCursor)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(1)
+ self.main_label = QLabel(text)
+ self.main_label.setAlignment(Qt.AlignCenter)
+ self.main_label.setWordWrap(True)
+ self.main_label.setAttribute(Qt.WA_TransparentForMouseEvents)
+ self.tr_label = QLabel(translation)
+ self.tr_label.setAlignment(Qt.AlignCenter)
+ self.tr_label.setWordWrap(True)
+ self.tr_label.setAttribute(Qt.WA_TransparentForMouseEvents)
+ self.tr_label.setVisible(bool(translation))
+ layout.addWidget(self.main_label)
+ if translation:
+ layout.addWidget(self.tr_label)
+ self.apply(False)
+
+ def _style_main(self, hover=False):
+ color = "#111014" if self.current else (
+ "rgba(28,27,31,188)" if hover else "rgba(28,27,31,118)")
+ self.main_label.setStyleSheet(
+ "color:%s; background:transparent; font-size:%spx; font-weight:%s;" % (
+ color, "24" if self.current else "17",
+ "600" if self.current else "400"))
+
+ def apply(self, current):
+ self.current = current
+ self._style_main()
+ if self.translation:
+ self.tr_label.setStyleSheet(
+ "color:%s; background:transparent; font-size:%spx; font-weight:400;" % (
+ "rgba(28,27,31,173)" if current else "rgba(28,27,31,108)",
+ "14" if current else "13"))
+
+ def enterEvent(self, event):
+ self._style_main(hover=True)
+ super().enterEvent(event)
+
+ def leaveEvent(self, event):
+ self._style_main()
+ super().leaveEvent(event)
+
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self.clicked.emit(self.index)
+ super().mousePressEvent(event)
+
+
+class LyricView(QScrollArea):
+ """当前行通过滚动条绝对定位到视口中心,切换时做 460ms 缓动。"""
+ seek_requested = Signal(float)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setObjectName("LyricView")
+ self.setWidgetResizable(True)
+ self.setFrameShape(QScrollArea.NoFrame)
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.viewport().setCursor(Qt.PointingHandCursor)
+ self.lines = []
+ self.rows = []
+ self.times = []
+ self.current_index = -1
+ self.content = None
+ self.top_spacer = None
+ self.bottom_spacer = None
+ self.animation = QPropertyAnimation(self.verticalScrollBar(), b"value", self)
+ self.animation.setDuration(460)
+ self.animation.setEasingCurve(QEasingCurve.OutCubic)
+
+ def count(self):
+ return len(self.rows)
+
+ def set_lines(self, lines):
+ self.animation.stop()
+ self.lines = lines or []
+ self.times = [line["t"] for line in self.lines]
+ self.current_index = -1
+ self.rows = []
+ self.content = QWidget(objectName="lyricContent")
+ layout = QVBoxLayout(self.content)
+ layout.setContentsMargins(16, 0, 16, 0)
+ layout.setSpacing(0)
+ self.top_spacer = QWidget()
+ self.bottom_spacer = QWidget()
+ layout.addWidget(self.top_spacer)
+ if not self.lines:
+ row = LyricRow(0, "暂无歌词", "", 64)
+ row.main_label.setStyleSheet("color:#9b968e; font-size:14px;")
+ row.setCursor(Qt.ArrowCursor)
+ layout.addWidget(row)
+ else:
+ for i, line in enumerate(self.lines):
+ row = LyricRow(i, line["text"], line.get("tr") or "", 70 if line.get("tr") else 48)
+ row.clicked.connect(self._row_clicked)
+ layout.addWidget(row)
+ self.rows.append(row)
+ layout.addWidget(self.bottom_spacer)
+ self.setWidget(self.content)
+ self._update_spacers()
+ self.verticalScrollBar().setValue(0)
+
+ def _paint_row(self, row, current):
+ row.apply(current)
+
+ def _row_clicked(self, index):
+ if 0 <= index < len(self.lines):
+ self.seek_requested.emit(self.lines[index]["t"] * 1000 + 10)
+
+ def set_position(self, seconds, force=False, animate=True):
+ if not self.lines:
+ return
+ index = bisect.bisect_right(self.times, seconds + 0.10) - 1
+ if index == self.current_index and not force:
+ return
+ previous = self.current_index
+ self.current_index = index
+ if 0 <= previous < len(self.rows):
+ self._paint_row(self.rows[previous], False)
+ if 0 <= index < len(self.rows):
+ self._paint_row(self.rows[index], True)
+ self.center_current(animate)
+
+ def center_current(self, animate=True):
+ index = self.current_index
+ if not (0 <= index < len(self.rows)):
+ return
+ row = self.rows[index]
+ target = int(row.y() + row.height() / 2 - self.viewport().height() / 2)
+ bar = self.verticalScrollBar()
+ target = max(bar.minimum(), min(bar.maximum(), target))
+ self.animation.stop()
+ if animate and self.isVisible():
+ self.animation.setStartValue(bar.value())
+ self.animation.setEndValue(target)
+ self.animation.start()
+ else:
+ bar.setValue(target)
+
+ def center_error(self):
+ """测试用:当前行中心与视口中心的像素误差。"""
+ if not (0 <= self.current_index < len(self.rows)):
+ return None
+ row = self.rows[self.current_index]
+ bar = self.verticalScrollBar()
+ row_center = row.y() + row.height() / 2 - bar.value()
+ return row_center - self.viewport().height() / 2
+
+ def _update_spacers(self):
+ if not self.top_spacer:
+ return
+ # 上下各留半屏,首行与末行也能精确落在视口中心
+ pad = max(0, self.viewport().height() // 2)
+ self.top_spacer.setFixedHeight(pad)
+ self.bottom_spacer.setFixedHeight(pad)
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ self._update_spacers()
+ QTimer.singleShot(0, lambda: self.center_current(False))
+
+ def wheelEvent(self, event):
+ self.animation.stop()
+ super().wheelEvent(event)
+
+
+class TrackTable(QTableWidget):
+ play_requested = Signal(list, int)
+ COLS = ["", "歌曲", "专辑", "时长"]
+
+ def __init__(self, parent=None):
+ super().__init__(0, 4, parent)
+ self.songs = []
+ self.playing_id = None
+ self.covers = {}
+ self._hover_row = -1
+ self.setObjectName("trackTable")
+ self.setHorizontalHeaderLabels(self.COLS)
+ self.verticalHeader().setVisible(False)
+ self.verticalHeader().setDefaultSectionSize(66)
+ self.setShowGrid(False)
+ self.setWordWrap(False)
+ self.setTextElideMode(Qt.ElideRight)
+ self.setSelectionBehavior(QAbstractItemView.SelectRows)
+ self.setSelectionMode(QAbstractItemView.NoSelection)
+ self.setEditTriggers(QAbstractItemView.NoEditTriggers)
+ self.setFocusPolicy(Qt.NoFocus)
+ self.setMouseTracking(True)
+ self.viewport().setMouseTracking(True)
+ self.setCursor(Qt.PointingHandCursor)
+ self.viewport().setCursor(Qt.PointingHandCursor)
+ self.setToolTip("单击任意歌曲即可播放")
+ self.setStyleSheet("""
+ QTableWidget#trackTable { background:#fffefa; border:1px solid #e8e4de; border-radius:12px; }
+ QTableWidget#trackTable::item { border-bottom:1px solid #f0ede8; }
+ QTableWidget#trackTable::item:selected { background:#f3efea; color:#242322; }
+ """)
+ header = self.horizontalHeader()
+ header.hide()
+ header.setFixedHeight(36)
+ header.setSectionResizeMode(0, QHeaderView.Fixed)
+ self.setColumnWidth(0, 52)
+ header.setSectionResizeMode(1, QHeaderView.Stretch)
+ header.setSectionResizeMode(2, QHeaderView.Fixed)
+ self.setColumnWidth(2, 190)
+ header.setSectionResizeMode(3, QHeaderView.Fixed)
+ self.setColumnWidth(3, 72)
+ for i, alignment in enumerate((Qt.AlignCenter, Qt.AlignLeft | Qt.AlignVCenter,
+ Qt.AlignLeft | Qt.AlignVCenter, Qt.AlignRight | Qt.AlignVCenter)):
+ item = self.horizontalHeaderItem(i)
+ if item:
+ item.setTextAlignment(alignment)
+ self.clicked.connect(self._on_click)
+
+ def set_songs(self, songs):
+ self.songs = songs or []
+ self.playing_id = None
+ self.covers = {}
+ self.clearContents()
+ self.setRowCount(len(self.songs))
+ for i, song in enumerate(self.songs):
+ index = QTableWidgetItem("%02d" % (i + 1))
+ index.setTextAlignment(Qt.AlignCenter)
+ index.setForeground(QColor("#aaa59c"))
+ self.setItem(i, 0, index)
+
+ cell = QWidget()
+ cell.setCursor(Qt.PointingHandCursor)
+ cell.setAttribute(Qt.WA_TransparentForMouseEvents)
+ row = QHBoxLayout(cell)
+ row.setContentsMargins(2, 7, 10, 7)
+ row.setSpacing(13)
+ cover = QLabel("♪")
+ cover.setFixedSize(44, 44)
+ cover.setAlignment(Qt.AlignCenter)
+ cover.setCursor(Qt.PointingHandCursor)
+ cover.setStyleSheet(
+ "background:#eeeae4; border-radius:8px; color:#aaa59c; font-size:17px;")
+ row.addWidget(cover)
+ text = QVBoxLayout()
+ text.setSpacing(2)
+ title_row = QHBoxLayout()
+ title_row.setSpacing(6)
+ name = QLabel(song.get("name", ""))
+ name.setCursor(Qt.PointingHandCursor)
+ name.setStyleSheet("font-size:13.5px; font-weight:600; color:#2c2a27; background:transparent;")
+ name.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
+ title_row.addWidget(name, 1)
+ if core.is_vip(song):
+ vip = QLabel("VIP")
+ vip.setCursor(Qt.PointingHandCursor)
+ vip.setStyleSheet(
+ "font-size:9px; font-weight:700; color:#c83b32; border:1px solid #e9c0bb;"
+ "border-radius:3px; padding:0 3px; background:transparent;")
+ title_row.addWidget(vip, 0)
+ title_row.addStretch()
+ artist = QLabel(core.artists_of(song))
+ artist.setCursor(Qt.PointingHandCursor)
+ artist.setStyleSheet("font-size:11px; color:#99948b; background:transparent;")
+ text.addLayout(title_row)
+ text.addWidget(artist)
+ row.addLayout(text, 1)
+ for child in cell.findChildren(QLabel):
+ child.setAttribute(Qt.WA_TransparentForMouseEvents)
+ self.setCellWidget(i, 1, cell)
+ self.setItem(i, 1, QTableWidgetItem(""))
+
+ album = QTableWidgetItem(core.album_of(song))
+ album.setForeground(QColor("#8e8981"))
+ album.setToolTip(core.album_of(song))
+ self.setItem(i, 2, album)
+ duration = QTableWidgetItem(core.fmt_ms(song.get("dt")))
+ duration.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
+ duration.setForeground(QColor("#aaa59c"))
+ self.setItem(i, 3, duration)
+ self._paint_row_state(i)
+ self.verticalScrollBar().setValue(0)
+
+ def _paint_row_state(self, row, hover=None):
+ if not (0 <= row < self.rowCount()):
+ return
+ current = bool(self.songs and 0 <= row < len(self.songs)
+ and self.songs[row].get("id") == self.playing_id)
+ hot = self._hover_row if hover is None else hover
+ color = QColor("#fdf3f1" if current else ("#f5f2ed" if row == hot else "#fffefa"))
+ for col in (0, 2, 3):
+ item = self.item(row, col)
+ if item:
+ item.setBackground(color)
+ cell = self.cellWidget(row, 1)
+ if cell:
+ cell.setStyleSheet("background:%s;" % color.name())
+
+ def mouseMoveEvent(self, event):
+ row = self.indexAt(event.position().toPoint()).row()
+ if row != self._hover_row:
+ previous = self._hover_row
+ self._hover_row = row
+ if previous >= 0:
+ self._paint_row_state(previous)
+ if row >= 0:
+ self._paint_row_state(row)
+ super().mouseMoveEvent(event)
+
+ def leaveEvent(self, event):
+ previous = self._hover_row
+ self._hover_row = -1
+ if previous >= 0:
+ self._paint_row_state(previous)
+ super().leaveEvent(event)
+
+ def set_cover(self, index, pixmap):
+ if not (0 <= index < len(self.songs)):
+ return
+ cell = self.cellWidget(index, 1)
+ if not cell:
+ return
+ cover = cell.findChild(QLabel, "trackCover")
+ if not cover:
+ labels = cell.findChildren(QLabel)
+ cover = labels[0] if labels else None
+ if not cover:
+ return
+ cover.setObjectName("trackCover")
+ cover.setText("")
+ cover.setPixmap(rounded_pixmap(pixmap, 44, 8))
+ self.covers[index] = True
+
+ def set_playing(self, song_id):
+ self.playing_id = song_id
+ for i, song in enumerate(self.songs):
+ current = song.get("id") == song_id
+ cell = self.cellWidget(i, 1)
+ if cell:
+ labels = cell.findChildren(QLabel)
+ if labels:
+ title = labels[1] if len(labels) > 1 else labels[0]
+ title.setStyleSheet(
+ "font-size:13.5px; font-weight:600; color:%s; background:transparent;"
+ % ("#e6493f" if current else "#2c2a27"))
+ marker = "▶" if current else "%02d" % (i + 1)
+ if self.item(i, 0):
+ self.item(i, 0).setText(marker)
+ self.item(i, 0).setForeground(QColor("#e6493f" if current else "#aaa59c"))
+ self._paint_row_state(i)
+
+ def _on_click(self, index):
+ row = index.row()
+ if 0 <= row < len(self.songs):
+ self.play_requested.emit(self.songs, row)
+
+
+def icon_button(name, size=36, icon_size=18, color="#5f5b55", tooltip=""):
+ button = QPushButton(objectName="iconBtn")
+ button.setFixedSize(size, size)
+ button.setCursor(Qt.PointingHandCursor)
+ button.setIcon(make_icon(name, color, icon_size))
+ button.setIconSize(QSize(icon_size, icon_size))
+ if tooltip:
+ button.setToolTip(tooltip)
+ return button