feat: add NetEase desktop player source (1.2.2)

This commit is contained in:
yuncishu committed 2026-10-31 22:13:24 +08:00
1 parent 4d40b0bd6d
commit 1fccf2bed8
12 files changed
+2843

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
.venv/
venv/
build/
dist/
*.spec
*.exe
.env
.DS_Store
Thumbs.db
+56
View File
@@ -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)。
+21
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
PySide6>=6.12.0,<7
requests>=2.34.0,<3
+161
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+104
View File
@@ -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()
+1298
View File
File diff suppressed because it is too large. Load diff
+182
View File
@@ -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; }
"""
+400
View File
@@ -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))
+7
View File
@@ -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/"
+601
View File
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">%s</svg>'
ICONS = {
"play": _S % '<path d="M8.2 5.1v13.8L19.2 12z" fill="COLOR"/>',
"pause": _S % '<rect x="7.2" y="5" width="3.3" height="14" rx="1.1" fill="COLOR"/><rect x="13.5" y="5" width="3.3" height="14" rx="1.1" fill="COLOR"/>',
"prev": _S % '<rect x="5.8" y="5" width="2.3" height="14" rx="1" fill="COLOR"/><path d="M19.4 5.7v12.6L9.2 12z" fill="COLOR"/>',
"next": _S % '<rect x="15.9" y="5" width="2.3" height="14" rx="1" fill="COLOR"/><path d="M4.6 5.7v12.6L14.8 12z" fill="COLOR"/>',
"volume": _S % '<path d="M3.8 9.5h3.4L12.5 5.2v13.6l-5.3-4.3H3.8z" fill="COLOR"/><path d="M15.5 8.8a4.5 4.5 0 010 6.4M17.8 6.5a7.7 7.7 0 010 11" stroke="COLOR" stroke-width="1.6" fill="none" stroke-linecap="round"/>',
"volume-mute": _S % '<path d="M3.8 9.5h3.4L12.5 5.2v13.6l-5.3-4.3H3.8z" fill="COLOR"/><path d="M16 9.5l4.5 5M20.5 9.5l-4.5 5" stroke="COLOR" stroke-width="1.7" fill="none" stroke-linecap="round"/>',
"lyric": _S % '<path d="M5 6.5h14M5 11.8h10M5 17.1h12" stroke="COLOR" stroke-width="1.7" fill="none" stroke-linecap="round"/>',
"repeat": _S % '<path d="M17.3 4.3l3 3-3 3M19.3 7.3H9a4.3 4.3 0 00-4.3 4.3M6.7 19.7l-3-3 3-3M4.7 16.7h10.3a4.3 4.3 0 004.3-4.3" stroke="COLOR" stroke-width="1.65" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
"single": _S % '<path d="M17.3 4.3l3 3-3 3M19.3 7.3H9a4.3 4.3 0 00-4.3 4.3M6.7 19.7l-3-3 3-3M4.7 16.7h10.3a4.3 4.3 0 004.3-4.3M12 10.2v3.8" stroke="COLOR" stroke-width="1.65" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
"shuffle": _S % '<path d="M4 7h3.2l9.4 10H20M17.3 14.3l3 3-3 3M4 17h3.2l2.5-2.7M13.1 8.3l2.1-2.3H20M17.3 3.3l3 3-3 3" stroke="COLOR" stroke-width="1.65" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
"close": _S % '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11" stroke="COLOR" stroke-width="1.7" fill="none" stroke-linecap="round"/>',
"refresh": _S % '<path d="M19.2 12a7.2 7.2 0 11-2.1-5.1M19.5 4.4v4.4h-4.4" stroke="COLOR" stroke-width="1.7" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
"search": _S % '<circle cx="10.8" cy="10.8" r="6.3" stroke="COLOR" stroke-width="1.7" fill="none"/><path d="M15.6 15.6l4.2 4.2" stroke="COLOR" stroke-width="1.7" stroke-linecap="round"/>',
"chart": _S % '<path d="M4.5 19.5v-8.5M9.5 19.5v-14M14.5 19.5v-6M19.5 19.5V8" stroke="COLOR" stroke-width="1.8" fill="none" stroke-linecap="round"/>',
"clock": _S % '<circle cx="12" cy="12" r="7.5" stroke="COLOR" stroke-width="1.65" fill="none"/><path d="M12 7.5V12l3.2 2" stroke="COLOR" stroke-width="1.65" fill="none" stroke-linecap="round"/>',
"back": _S % '<path d="M15.5 5l-7 7 7 7" stroke="COLOR" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
"plus": _S % '<path d="M12 5v14M5 12h14" stroke="COLOR" stroke-width="1.8" fill="none" stroke-linecap="round"/>',
"playlist": _S % '<path d="M4.5 6.5h10M4.5 11.5h10M4.5 16.5h6.5" stroke="COLOR" stroke-width="1.6" fill="none" stroke-linecap="round"/><path d="M17 11v7.2" stroke="COLOR" stroke-width="1.6" stroke-linecap="round"/><circle cx="15.2" cy="18.2" r="1.9" fill="COLOR"/>',
"expand": _S % '<path d="M8.5 4.5h-4v4M15.5 4.5h4v4M19.5 15.5v4h-4M4.5 15.5v4h4" stroke="COLOR" stroke-width="1.7" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
"music": _S % '<path d="M9 18.2V6.5l10-2v11.3M9 9.3l10-2" stroke="COLOR" stroke-width="1.7" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6.5" cy="18.2" r="2.5" fill="COLOR"/><circle cx="16.5" cy="15.8" r="2.5" fill="COLOR"/>',
"netease": _S % '<circle cx="12" cy="12" r="11" fill="COLOR"/><path d="M8.1 14.6c-1.6-1-1.8-3.4-.4-4.7 1.2-1.1 3.1-1 4.1.2l4.8 5.8c.9 1.1.6 2.6-.4 3.3-1.1.8-2.7.4-3.3-.7l-2.1-3.4 3.7 4.4M8.6 8.1l5.5 7.1M13.3 7.2c1.2-.4 2.6.2 3.2 1.3.6 1.2.2 2.6-.8 3.3" stroke="#ffffff" stroke-width="1.25" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
}
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