feat: add NetEase desktop player source (1.2.2)
This commit is contained in:
1 parent
4d40b0bd6d
commit
1fccf2bed8
12 files changed
+2843
No files matched your search
+11
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
*.exe
|
||||
.env
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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)。
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
PySide6>=6.12.0,<7
|
||||
requests>=2.34.0,<3
|
||||
+161
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -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
File diff suppressed because it is too large.
Load diff
+182
@@ -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
@@ -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))
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user