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