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 = '%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