4 Commits
Author SHA1 Message Date
yuncishu 52aee3b5d5 fix: update installer never ran, app closed without updating (1.2.6)
The installer was spawned with DETACHED_PROCESS, which makes the
host process exit as soon as it starts, so the script body never
executed: no log, no file swap, no restart. Only CREATE_NO_WINDOW
is needed to keep the window hidden.

- stop both the bootloader and the Python child of a onefile build
- verify the script process is still alive before quitting the app
- write logs to updates/logs and always relaunch on failure
2026-10-31 23:52:53 +08:00
yuncishu 535e52d5f0 chore: add download availability notice (1.2.5) 2026-10-31 23:23:24 +08:00
yuncishu e0789d4a28 fix: make update installer atomic and Unicode-safe (1.2.4) 2026-10-31 23:05:59 +08:00
yuncishu 2a55d0dc2c feat: redesign play queue and per-track queue actions (1.2.3) 2026-10-31 22:38:59 +08:00
6 changed files with 526 additions and 127 deletions

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
- 标准 / 较高 / 极高 / 无损音质切换 - 标准 / 较高 / 极高 / 无损音质切换
- 逐行歌词、精确居中、点击歌词跳转 - 逐行歌词、精确居中、点击歌词跳转
- 全屏播放页、黑胶旋转、音量与播放进度控制 - 全屏播放页、黑胶旋转、音量与播放进度控制
- 播放栏显示当前播放歌单 - 播放队列面板:下一首播放、删除单曲、清空保留当前
- 应用内检查更新、自动下载、SHA-256 校验和重启安装 - 应用内检查更新、自动下载、SHA-256 校验和重启安装
## 开发运行 ## 开发运行
+81 -79
View File
@@ -7,11 +7,11 @@ import sys
import requests import requests
from PySide6.QtCore import Qt, QUrl, QSize, QTimer, QRunnable, QThreadPool, Signal, QObject from PySide6.QtCore import Qt, QUrl, QSize, QTimer, QRunnable, QThreadPool, Signal, QObject
from PySide6.QtGui import QPixmap, QIcon, QColor from PySide6.QtGui import QPixmap, QIcon, QCursor
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QLineEdit, QStackedWidget, QListWidget, QListWidgetItem, QSlider, QComboBox, QLineEdit, QStackedWidget, QSlider, QComboBox, QToolTip,
QGridLayout, QSizePolicy, QProgressBar, QDialog, QGridLayout, QSizePolicy, QProgressBar, QDialog,
) )
@@ -22,7 +22,7 @@ from version import APP_NAME, APP_VERSION
from theme import QSS, ACCENT from theme import QSS, ACCENT
from widgets import ( from widgets import (
make_icon, rounded_pixmap, DiscWidget, ClickSlider, LyricView, TrackTable, icon_button, make_icon, rounded_pixmap, DiscWidget, ClickSlider, LyricView, TrackTable, icon_button,
CoverBackdrop, CoverBackdrop, QueuePopup,
) )
@@ -108,8 +108,6 @@ class MainWindow(QMainWindow):
self.queue = [] self.queue = []
self.q_index = -1 self.q_index = -1
self.current = None self.current = None
self.stack_context_name = "发现音乐"
self.current_playlist_name = ""
self.mode = "list" self.mode = "list"
self.quality = "exhigh" self.quality = "exhigh"
self.volume = 78 self.volume = 78
@@ -230,6 +228,7 @@ class MainWindow(QMainWindow):
self.stack.addWidget(self.charts_page) self.stack.addWidget(self.charts_page)
self.table = TrackTable() self.table = TrackTable()
self.table.play_requested.connect(self._play_from_list) self.table.play_requested.connect(self._play_from_list)
self.table.next_requested.connect(self._queue_next)
self.table.verticalScrollBar().valueChanged.connect(self._load_visible_covers) self.table.verticalScrollBar().valueChanged.connect(self._load_visible_covers)
self.stack.addWidget(self.table) self.stack.addWidget(self.table)
self.empty_page = self._build_empty_page() self.empty_page = self._build_empty_page()
@@ -242,6 +241,11 @@ class MainWindow(QMainWindow):
self.fs.setParent(root) self.fs.setParent(root)
self.fs.hide() self.fs.hide()
self.queue_popup = QueuePopup(self)
self.queue_popup.play_requested.connect(self._play_index)
self.queue_popup.remove_requested.connect(self._remove_queue_item)
self.queue_popup.clear_requested.connect(self._clear_queue)
def _build_sidebar(self): def _build_sidebar(self):
side = QWidget(objectName="side") side = QWidget(objectName="side")
side.setFixedWidth(224) side.setFixedWidth(224)
@@ -281,26 +285,11 @@ class MainWindow(QMainWindow):
layout.addWidget(button) layout.addWidget(button)
self.nav_btns[key] = button self.nav_btns[key] = button
queue_header = QHBoxLayout() layout.addStretch()
queue_header.setContentsMargins(12, 28, 6, 8) client_notice = QLabel("客户端暂不提供直链下载", objectName="clientNotice")
queue_header.addWidget(QLabel("当前队列", objectName="sideSection")) client_notice.setContentsMargins(10, 0, 6, 4)
queue_header.addStretch() client_notice.setWordWrap(True)
clear = QPushButton("清空", objectName="clearBtn") layout.addWidget(client_notice)
clear.setCursor(Qt.PointingHandCursor)
clear.clicked.connect(self._clear_queue)
queue_header.addWidget(clear)
layout.addLayout(queue_header)
self.queue_list = QListWidget(objectName="queue")
self.queue_list.setCursor(Qt.PointingHandCursor)
self.queue_list.viewport().setCursor(Qt.PointingHandCursor)
self.queue_list.itemClicked.connect(self._on_queue_click)
layout.addWidget(self.queue_list, 1)
self.queue_empty = QLabel("播放的歌曲会出现在这里", objectName="queueEmpty")
self.queue_empty.setContentsMargins(12, 4, 8, 8)
layout.addWidget(self.queue_empty)
layout.addSpacing(8)
version_row = QHBoxLayout() version_row = QHBoxLayout()
version_row.setContentsMargins(10, 8, 2, 0) version_row.setContentsMargins(10, 8, 2, 0)
self.version_label = QLabel("v%s" % APP_VERSION, objectName="versionLabel") self.version_label = QLabel("v%s" % APP_VERSION, objectName="versionLabel")
@@ -468,11 +457,8 @@ class MainWindow(QMainWindow):
layout.addLayout(center, 1) layout.addLayout(center, 1)
right_widget = QWidget() right_widget = QWidget()
right_widget.setFixedWidth(290) right_widget.setFixedWidth(324)
right_column = QVBoxLayout(right_widget) right = QHBoxLayout(right_widget)
right_column.setContentsMargins(0, 1, 0, 0)
right_column.setSpacing(4)
right = QHBoxLayout()
right.setContentsMargins(0, 0, 0, 0) right.setContentsMargins(0, 0, 0, 0)
right.setSpacing(7) right.setSpacing(7)
right.addStretch() right.addStretch()
@@ -502,12 +488,9 @@ class MainWindow(QMainWindow):
self.vol_slider.setToolTip("音量") self.vol_slider.setToolTip("音量")
self.vol_slider.valueChanged.connect(self._on_volume) self.vol_slider.valueChanged.connect(self._on_volume)
right.addWidget(self.vol_slider) right.addWidget(self.vol_slider)
right_column.addLayout(right) self.btn_queue = icon_button("bars", 34, 18, tooltip="播放队列")
self.bar_playlist = QLabel("歌单 · 未播放", objectName="barPlaylist") self.btn_queue.clicked.connect(self._toggle_queue_popup)
self.bar_playlist.setAlignment(Qt.AlignRight | Qt.AlignVCenter) right.addWidget(self.btn_queue)
self.bar_playlist.setFixedHeight(14)
self.bar_playlist.setToolTip("当前播放队列来源")
right_column.addWidget(self.bar_playlist)
layout.addWidget(right_widget) layout.addWidget(right_widget)
return bar return bar
@@ -629,20 +612,17 @@ class MainWindow(QMainWindow):
self.page_eyebrow.setText("LIBRARY / DISCOVER") self.page_eyebrow.setText("LIBRARY / DISCOVER")
self.page_title.setText("发现音乐") self.page_title.setText("发现音乐")
self.page_meta.setText("网易云音乐官方榜单") self.page_meta.setText("网易云音乐官方榜单")
self.stack_context_name = "发现音乐"
self.stack.setCurrentWidget(self.charts_page) self.stack.setCurrentWidget(self.charts_page)
elif key == "search": elif key == "search":
self.page_eyebrow.setText("LIBRARY / SEARCH") self.page_eyebrow.setText("LIBRARY / SEARCH")
self.page_title.setText("搜索") self.page_title.setText("搜索")
self.page_meta.setText("在网易云音乐曲库中寻找声音") self.page_meta.setText("在网易云音乐曲库中寻找声音")
self.stack_context_name = "搜索"
self._show_empty("搜索一首歌", "输入歌曲、歌手或专辑名称,按回车开始") self._show_empty("搜索一首歌", "输入歌曲、歌手或专辑名称,按回车开始")
self.search_input.setFocus() self.search_input.setFocus()
else: else:
self.page_eyebrow.setText("LIBRARY / HISTORY") self.page_eyebrow.setText("LIBRARY / HISTORY")
self.page_title.setText("最近播放") self.page_title.setText("最近播放")
self.page_meta.setText("保存在这台电脑上的播放记录") self.page_meta.setText("保存在这台电脑上的播放记录")
self.stack_context_name = "最近播放"
if not self.recent: if not self.recent:
self._show_empty("还没有播放记录", "播放过的歌曲会自动保存在这里") self._show_empty("还没有播放记录", "播放过的歌曲会自动保存在这里")
else: else:
@@ -788,7 +768,6 @@ class MainWindow(QMainWindow):
token = self.view_token token = self.view_token
self.page_eyebrow.setText("CHARTS / OFFICIAL") self.page_eyebrow.setText("CHARTS / OFFICIAL")
self.page_title.setText(name) self.page_title.setText(name)
self.stack_context_name = name
self.btn_back.setVisible(True) self.btn_back.setVisible(True)
self.btn_refresh.setVisible(False) self.btn_refresh.setVisible(False)
self.btn_play_all.setVisible(False) self.btn_play_all.setVisible(False)
@@ -877,7 +856,6 @@ class MainWindow(QMainWindow):
) )
return return
self.page_title.setText(playlist.get("name") or "导入歌单") self.page_title.setText(playlist.get("name") or "导入歌单")
self.stack_context_name = playlist.get("name") or "导入歌单"
total = playlist.get("trackCount") or len(songs) total = playlist.get("trackCount") or len(songs)
extra = " · 当前导入前 %d 首" % len(songs) if total > len(songs) else "" extra = " · 当前导入前 %d 首" % len(songs) if total > len(songs) else ""
self.page_meta.setText("共 %d 首%s · 来自网易云音乐" % (total, extra)) self.page_meta.setText("共 %d 首%s · 来自网易云音乐" % (total, extra))
@@ -923,7 +901,6 @@ class MainWindow(QMainWindow):
self.view_token += 1 self.view_token += 1
token = self.view_token token = self.view_token
self.page_meta.setText("正在搜索「%s」…" % keyword) self.page_meta.setText("正在搜索「%s」…" % keyword)
self.stack_context_name = "搜索 · %s" % keyword
self.btn_play_all.setVisible(False) self.btn_play_all.setVisible(False)
self._show_empty("正在搜索", "在整个曲库中寻找「%s」" % keyword, busy=True) self._show_empty("正在搜索", "在整个曲库中寻找「%s」" % keyword, busy=True)
@@ -950,41 +927,79 @@ class MainWindow(QMainWindow):
# ---------- 队列 ---------- # ---------- 队列 ----------
def _render_queue(self): def _render_queue(self):
self.queue_list.clear() if hasattr(self, "queue_popup") and self.queue_popup.isVisible():
for i, song in enumerate(self.queue): current_id = self.current.get("id") if self.current else None
item = QListWidgetItem("%s\n%s" % (song.get("name", ""), core.artists_of(song))) self.queue_popup.set_queue(self.queue, self.q_index, current_id)
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): def _toggle_queue_popup(self):
index = item.data(Qt.UserRole) if self.queue_popup.isVisible():
if index is not None and 0 <= int(index) < len(self.queue): self.queue_popup.hide()
self._play_index(int(index)) return
current_id = self.current.get("id") if self.current else None
self.queue_popup.set_queue(self.queue, self.q_index, current_id)
self.queue_popup.show_above(self.btn_queue)
def _remove_queue_item(self, index):
if not (0 <= index < len(self.queue)):
return
was_current = index == self.q_index
self.queue.pop(index)
if was_current:
self.q_index = index - 1
elif index < self.q_index:
self.q_index -= 1
if not self.queue:
self.q_index = -1
elif self.q_index >= len(self.queue):
self.q_index = len(self.queue) - 1
self._render_queue()
def _clear_queue(self): def _clear_queue(self):
self.queue = [] if self.current is not None:
self.q_index = -1 self.queue = [self.current]
self.q_index = 0
else:
self.queue = []
self.q_index = -1
self._render_queue() self._render_queue()
def _queue_next(self, song):
if not song:
return
if self.current and song.get("id") == self.current.get("id"):
QToolTip.showText(QCursor.pos(), "这首歌正在播放")
return
existing = next(
(i for i, item in enumerate(self.queue) if item.get("id") == song.get("id")),
None,
)
if existing is not None:
self.queue.pop(existing)
if existing < self.q_index:
self.q_index -= 1
insert_at = self.q_index + 1 if self.q_index >= 0 else 0
insert_at = max(0, min(insert_at, len(self.queue)))
self.queue.insert(insert_at, dict(song))
self._render_queue()
QToolTip.showText(QCursor.pos(), "已设为下一首播放")
# ---------- 播放 ---------- # ---------- 播放 ----------
def _play_from_list(self, songs, index): def _play_from_list(self, songs, index):
self.queue = list(songs) if not (0 <= index < len(songs)):
self._set_current_playlist(self.stack_context_name) return
self._play_index(index) song = dict(songs[index])
existing = next(
(i for i, item in enumerate(self.queue) if item.get("id") == song.get("id")),
None,
)
if existing is None:
self.queue.append(song)
existing = len(self.queue) - 1
self._play_index(existing)
def _play_all(self): def _play_all(self):
if self.stack_songs: if self.stack_songs:
self.queue = list(self.stack_songs) self.queue = list(self.stack_songs)
self._set_current_playlist(self.stack_context_name)
self._play_index(0) self._play_index(0)
def _play_index(self, index): def _play_index(self, index):
@@ -1147,19 +1162,6 @@ class MainWindow(QMainWindow):
lambda error: self.bar_artist.setText("音质切换失败")) 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): def _update_bar(self):
song = self.current song = self.current
if not song: if not song:
+36 -13
View File
@@ -29,15 +29,7 @@ QPushButton#nav {
} }
QPushButton#nav:hover { color: #f3f3f4; background: #28292e; } QPushButton#nav:hover { color: #f3f3f4; background: #28292e; }
QPushButton#nav[active="true"] { color: #ffffff; background: #323338; font-weight: 600; } QPushButton#nav[active="true"] { color: #ffffff; background: #323338; font-weight: 600; }
QPushButton#clearBtn { color: #777981; border: none; background: transparent; font-size: 11px; } #clientNotice { color: #65676f; font-size: 9.5px; letter-spacing: .1px; }
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; } #versionLabel { color: #666871; font-size: 9.5px; letter-spacing: .4px; }
QPushButton#versionBtn { QPushButton#versionBtn {
color: #8b8d95; background: transparent; border: none; border-radius: 5px; color: #8b8d95; background: transparent; border: none; border-radius: 5px;
@@ -110,10 +102,6 @@ QProgressBar#emptyProgress::chunk { background: #e6493f; border-radius: 2px; }
#barName { font-size: 13px; font-weight: 650; color: #2c2a27; } #barName { font-size: 13px; font-weight: 650; color: #2c2a27; }
#barArtist { font-size: 11px; color: #918d85; } #barArtist { font-size: 11px; color: #918d85; }
#barHint { color: #b0aba3; font-size: 10px; } #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; } #timeLabel { color: #9b978f; font-size: 10px; font-family: "Segoe UI", sans-serif; }
QSlider::groove:horizontal { height: 4px; background: #e8e4de; border-radius: 2px; } QSlider::groove:horizontal { height: 4px; background: #e8e4de; border-radius: 2px; }
QSlider::sub-page:horizontal { height: 4px; background: #e6493f; border-radius: 2px; } QSlider::sub-page:horizontal { height: 4px; background: #e6493f; border-radius: 2px; }
@@ -136,6 +124,41 @@ QComboBox QAbstractItemView {
selection-background-color: #eeeae4; selection-color: #242322; selection-background-color: #eeeae4; selection-color: #242322;
} }
/* 播放队列弹层 */
#queueCard { background:#fffefa; border:1px solid #e4dfd8; border-radius:14px; }
#queueTitle { color:#242322; font-size:15px; font-weight:700; }
#queueCount { color:#aaa49b; font-size:10px; }
QPushButton#queueClear {
color:#8d8880; background:transparent; border:1px solid #e6e1da;
border-radius:6px; padding:0 9px; min-height:25px; font-size:10.5px;
}
QPushButton#queueClear:hover { color:#c83b32; border-color:#e7c5c0; background:#fdf5f3; }
QPushButton#queueClear:disabled { color:#c9c4bc; border-color:#efebe5; }
QScrollArea#queueScroll { background:transparent; border:none; }
QScrollArea#queueScroll > QWidget > QWidget { background:transparent; }
QWidget#queueBody { background:transparent; }
QWidget#queueRow { background:transparent; border-radius:8px; }
QWidget#queueRow:hover { background:#f4f0ea; }
QWidget#queueRow[current="true"] { background:#fdf1ef; }
QPushButton#queueRemove {
background:transparent; border:none; border-radius:6px; padding:0;
}
QPushButton#queueRemove:hover { background:#efe7e1; }
#queueEmpty { color:#aaa49b; font-size:11px; padding:54px 0; }
QPushButton#trackMore {
background:transparent; border:none; border-radius:6px; padding:0;
}
QPushButton#trackMore:hover { background:#efebe5; }
QMenu {
background:#fffefa; border:1px solid #e1dcd4; border-radius:8px; padding:5px;
}
QMenu::item {
color:#4f4b46; background:transparent; padding:8px 20px 8px 14px;
border-radius:5px; font-size:12px;
}
QMenu::item:selected { background:#f4f0ea; color:#242322; }
QMenu::separator { height:1px; background:#eeeae3; margin:4px 8px; }
/* 导入歌单 */ /* 导入歌单 */
#importCard { background:#fffefa; border:1px solid #e4dfd8; border-radius:16px; } #importCard { background:#fffefa; border:1px solid #e4dfd8; border-radius:16px; }
#importTitle { color:#22211f; font-size:19px; font-weight:700; } #importTitle { color:#22211f; font-size:19px; font-weight:700; }
+226 -23
View File
@@ -5,6 +5,7 @@ import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
import urllib.parse import urllib.parse
import requests import requests
@@ -102,15 +103,169 @@ def _safe_suffix(url):
return suffix if suffix in (".exe", ".msi", ".zip") else ".exe" return suffix if suffix in (".exe", ".msi", ".zip") else ".exe"
def _offset_dir(version): CREATE_NO_WINDOW = 0x08000000
def _offset_dir(sub=""):
base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir() base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir()
path = os.path.join(base, "NeteasePlayer", "updates") path = os.path.join(base, "NeteasePlayer", "updates")
if sub:
path = os.path.join(path, sub)
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
return path return path
def _quote(path): def _ps_quote(value):
return '"' + str(path).replace('"', '""') + '"' return "'" + str(value).replace("'", "''") + "'"
def powershell_exe():
root = os.environ.get("SystemRoot") or os.environ.get("WINDIR") or r"C:\Windows"
path = os.path.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
return path if os.path.exists(path) else "powershell.exe"
def build_install_script(current, downloaded, pid, digest="", parent_pid=None):
"""生成 Unicode 安全的更新脚本:校验副本后再交换文件,任何失败路径都会重新拉起程序。"""
current = os.path.abspath(current)
downloaded = os.path.abspath(downloaded)
log_dir = _offset_dir("logs")
log_path = os.path.join(log_dir, "update.log")
pids = [int(pid)]
if parent_pid and int(parent_pid) > 100 and int(parent_pid) not in pids:
# PyInstaller 单文件有两个进程:bootloader 才是持有 exe 文件的那个
pids.append(int(parent_pid))
script = r"""
$ErrorActionPreference = 'Continue'
$src = __SRC__
$dst = __DST__
$expected = __EXPECTED__
$logDir = __LOG_DIR__
$log = __LOG__
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
function Write-UpdateLog($message) {
try {
Add-Content -LiteralPath $log -Encoding UTF8 -Value ("[" + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + "] " + $message)
} catch {}
}
function Start-App($path) {
if (-not $path) { return $false }
if (-not (Test-Path -LiteralPath $path)) {
Write-UpdateLog ("missing target: " + $path)
return $false
}
try {
Start-Process -FilePath $path -WorkingDirectory (Split-Path -Parent $path) -ErrorAction Stop
Write-UpdateLog ("started: " + $path)
return $true
} catch {
Write-UpdateLog ("start failed: " + $path + " :: " + $_.Exception.Message)
return $false
}
}
Write-UpdateLog "=== update begin; pids=__PIDS_TEXT__ ==="
Write-UpdateLog ("src=" + $src)
Write-UpdateLog ("dst=" + $dst)
Start-Sleep -Seconds 2
foreach ($p in @(__PIDS__)) {
try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch {}
}
Start-Sleep -Milliseconds 1200
$staged = $dst + ".update"
$backup = $dst + ".bak"
Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue
$verified = $false
for ($i = 0; $i -lt 30; $i++) {
try {
Copy-Item -LiteralPath $src -Destination $staged -Force -ErrorAction Stop
if ($expected) {
$actual = (Get-FileHash -LiteralPath $staged -Algorithm SHA256).Hash
if ($actual -ne $expected) { throw "SHA256 mismatch" }
}
$verified = $true
Write-UpdateLog "staged copy verified"
break
} catch {
Write-UpdateLog ("stage attempt " + $i + " failed: " + $_.Exception.Message)
Start-Sleep -Milliseconds 500
}
}
if (-not $verified) {
Write-UpdateLog "stage failed; launching downloaded copy"
if (-not (Start-App $src)) { Start-App $dst }
Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
exit
}
$swapped = $false
for ($i = 0; $i -lt 30; $i++) {
try {
if (Test-Path -LiteralPath $dst) {
Move-Item -LiteralPath $dst -Destination $backup -Force -ErrorAction Stop
}
$swapped = $true
break
} catch {
Write-UpdateLog ("rename old attempt " + $i + " failed: " + $_.Exception.Message)
Start-Sleep -Milliseconds 500
}
}
if (-not $swapped) {
Write-UpdateLog "cannot rename running exe; starting downloaded copy"
if (-not (Start-App $src)) { Start-App $dst }
Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
exit
}
$installed = $false
try {
Move-Item -LiteralPath $staged -Destination $dst -Force -ErrorAction Stop
$installed = $true
Write-UpdateLog "new file in place"
} catch {
Write-UpdateLog ("swap failed: " + $_.Exception.Message)
if ((Test-Path -LiteralPath $backup) -and -not (Test-Path -LiteralPath $dst)) {
Move-Item -LiteralPath $backup -Destination $dst -Force -ErrorAction SilentlyContinue
Write-UpdateLog "rolled back from backup"
}
}
if ($installed) {
if (-not (Start-App $dst)) {
Write-UpdateLog "new version did not start; trying downloaded copy"
Start-App $src
}
} else {
if (-not (Start-App $dst)) { Start-App $src }
}
for ($i = 0; $i -lt 20; $i++) {
if (-not (Test-Path -LiteralPath $backup)) { break }
try {
[System.IO.File]::Delete($backup)
Write-UpdateLog "old backup cleaned"
break
} catch {
Start-Sleep -Milliseconds 500
}
}
Write-UpdateLog "=== update end ==="
Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
""".replace("__SRC__", _ps_quote(downloaded)) \
.replace("__DST__", _ps_quote(current)) \
.replace("__EXPECTED__", _ps_quote(digest or "")) \
.replace("__LOG_DIR__", _ps_quote(log_dir)) \
.replace("__LOG__", _ps_quote(log_path)) \
.replace("__PIDS_TEXT__", ",".join(str(p) for p in pids)) \
.replace("__PIDS__", ", ".join(str(p) for p in pids))
return script, log_path
class UpdateDialog(QDialog): class UpdateDialog(QDialog):
@@ -352,8 +507,8 @@ class UpdateDialog(QDialog):
if action == "retry": if action == "retry":
self.primary.setProperty("action", "") self.primary.setProperty("action", "")
self.start_download() self.start_download()
elif action == "install": elif action == "page":
self.install() self._open_page()
else: else:
self.install() self.install()
@@ -367,24 +522,72 @@ class UpdateDialog(QDialog):
self.status.setText("安装包已打开") self.status.setText("安装包已打开")
return return
current = os.path.abspath(sys.executable) current = os.path.abspath(sys.executable)
script_path = os.path.join(tempfile.gettempdir(), "netease_player_update.cmd") parent_pid = None
script = "\r\n".join([ try:
"@echo off", candidate = os.getppid()
"chcp 65001 >nul", if candidate and candidate > 100:
"timeout /t 2 /nobreak >nul", parent_pid = candidate
"taskkill /PID %d /F >nul 2>&1" % os.getpid(), except Exception: # noqa: BLE001
'copy /Y %s %s >nul 2>&1' % (_quote(path), _quote(current)), parent_pid = None
'start "" %s' % _quote(current), script, log_path = build_install_script(
'del "%~f0" >nul 2>&1', current, path, os.getpid(), self.info.get("sha256") or "", parent_pid)
"", # 放在更新目录而不是 Temp,避免脚本尚未启动就被系统清理
]) script_path = os.path.join(_offset_dir("logs"), "install.ps1")
with open(script_path, "w", encoding="utf-8") as handle: try:
handle.write(script) with open(script_path, "w", encoding="utf-8-sig") as handle:
subprocess.Popen( handle.write(script)
["cmd.exe", "/c", script_path], except OSError as exc:
creationflags=0x08000000 | 0x00000008, self._manual_install(path, "无法写入更新脚本:%s" % exc)
close_fds=True, return
) try:
proc = subprocess.Popen(
[
powershell_exe(), "-NoProfile", "-NonInteractive",
"-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
"-File", script_path,
],
creationflags=CREATE_NO_WINDOW,
close_fds=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as exc: # noqa: BLE001
self._manual_install(path, "无法启动更新程序:%s" % exc)
return
# PowerShell 被安全策略或参数问题打断时会立刻退出,必须确认它真的活着
deadline = time.time() + 1.5
while time.time() < deadline:
if proc.poll() is not None:
break
time.sleep(0.1)
if proc.poll() is not None:
self._manual_install(path, "更新脚本未能运行")
return
self.status.setText("正在重启并安装,新版本会自动打开…")
self._notify("installing", "正在安装更新")
QApplication.quit()
def _manual_install(self, path, reason):
"""更新脚本无法代劳时,至少把新版本直接跑起来,不让用户只剩一个关掉的窗口。"""
try:
subprocess.Popen(
[path],
cwd=os.path.dirname(path),
creationflags=CREATE_NO_WINDOW,
close_fds=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as exc: # noqa: BLE001
self.status.setText("%s;请前往下载页手动安装" % reason)
self.primary.setText("前往下载页")
self.primary.setEnabled(True)
self.primary.setProperty("action", "page")
return
self.status.setText("%s,已直接启动新版本" % reason)
self._notify("installing", "已启动新版本")
QApplication.quit() QApplication.quit()
def _notify(self, state, text): def _notify(self, state, text):
+1 -1
View File
@@ -1,7 +1,7 @@
"""应用版本与更新地址。""" """应用版本与更新地址。"""
APP_NAME = "网易云播放器" APP_NAME = "网易云播放器"
APP_VERSION = "1.2.2" APP_VERSION = "1.2.6"
BUILD_DATE = "2026-10-31" BUILD_DATE = "2026-10-31"
UPDATE_ENDPOINT = "https://music.yuncishu.xyz/api/app/update" UPDATE_ENDPOINT = "https://music.yuncishu.xyz/api/app/update"
DOWNLOAD_PAGE = "https://music.yuncishu.xyz/download/" DOWNLOAD_PAGE = "https://music.yuncishu.xyz/download/"
+181 -10
View File
@@ -2,7 +2,7 @@
import bisect import bisect
from PySide6.QtCore import ( from PySide6.QtCore import (
Qt, QSize, QRectF, QByteArray, QTimer, QElapsedTimer, Qt, QSize, QPoint, QRectF, QByteArray, QTimer, QElapsedTimer,
QPropertyAnimation, QEasingCurve, Signal, QPropertyAnimation, QEasingCurve, Signal,
) )
from PySide6.QtGui import QPixmap, QIcon, QColor, QFont, QPainter, QPainterPath, QImage, QLinearGradient from PySide6.QtGui import QPixmap, QIcon, QColor, QFont, QPainter, QPainterPath, QImage, QLinearGradient
@@ -10,7 +10,7 @@ from PySide6.QtSvg import QSvgRenderer
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QLabel, QPushButton, QSlider, QStyle, QScrollArea, QVBoxLayout, QHBoxLayout, QWidget, QLabel, QPushButton, QSlider, QStyle, QScrollArea, QVBoxLayout, QHBoxLayout,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QSizePolicy, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QSizePolicy,
QGraphicsScene, QGraphicsBlurEffect, QGraphicsScene, QGraphicsBlurEffect, QFrame, QGraphicsDropShadowEffect, QMenu,
) )
import core import core
@@ -35,6 +35,8 @@ ICONS = {
"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"/>', "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"/>', "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"/>', "plus": _S % '<path d="M12 5v14M5 12h14" stroke="COLOR" stroke-width="1.8" fill="none" stroke-linecap="round"/>',
"bars": _S % '<path d="M4.5 7h15M4.5 12h15M4.5 17h15" stroke="COLOR" stroke-width="1.8" fill="none" stroke-linecap="round"/>',
"more": _S % '<circle cx="6" cy="12" r="1.6" fill="COLOR"/><circle cx="12" cy="12" r="1.6" fill="COLOR"/><circle cx="18" cy="12" r="1.6" fill="COLOR"/>',
"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"/>', "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"/>', "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"/>', "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"/>',
@@ -404,12 +406,160 @@ class LyricView(QScrollArea):
super().wheelEvent(event) super().wheelEvent(event)
class TrackTable(QTableWidget): class QueueRow(QWidget):
play_requested = Signal(list, int) play_requested = Signal(int)
COLS = ["", "歌曲", "专辑", "时长"] remove_requested = Signal(int)
def __init__(self, index, song, current=False, parent=None):
super().__init__(parent)
self.index = index
self.setObjectName("queueRow")
self.setProperty("current", "true" if current else "false")
self.setAttribute(Qt.WA_StyledBackground, True)
self.setFixedHeight(60)
self.setCursor(Qt.PointingHandCursor)
row = QHBoxLayout(self)
row.setContentsMargins(10, 4, 8, 4)
row.setSpacing(11)
number = QLabel("▶" if current else "%02d" % (index + 1), objectName="queueIndex")
number.setFixedWidth(24)
number.setAlignment(Qt.AlignCenter)
number.setStyleSheet("color:%s; font-size:%spx; font-weight:600; background:transparent;" % (
"#e6493f" if current else "#aaa59c", 9 if current else 10))
row.addWidget(number)
text = QVBoxLayout()
text.setSpacing(2)
title = QLabel(song.get("name", ""), objectName="queueSong")
title.setStyleSheet("color:%s; font-size:12.5px; font-weight:%s; background:transparent;" % (
"#e6493f" if current else "#2c2a27", "600" if current else "500"))
artist = QLabel(core.artists_of(song), objectName="queueArtist")
artist.setStyleSheet("color:%s; font-size:10px; background:transparent;" % (
"#c16b64" if current else "#9b968e"))
text.addWidget(title)
text.addWidget(artist)
row.addLayout(text, 1)
remove = QPushButton()
remove.setObjectName("queueRemove")
remove.setFixedSize(26, 26)
remove.setCursor(Qt.PointingHandCursor)
remove.setIcon(make_icon("close", "#aaa49b", 12))
remove.setIconSize(QSize(12, 12))
remove.setToolTip("从队列移除")
remove.clicked.connect(lambda: self.remove_requested.emit(self.index))
row.addWidget(remove)
for label in (number, title, artist):
label.setAttribute(Qt.WA_TransparentForMouseEvents)
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
self.play_requested.emit(self.index)
super().mouseReleaseEvent(event)
class QueuePopup(QFrame):
play_requested = Signal(int)
remove_requested = Signal(int)
clear_requested = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(0, 4, parent) super().__init__(parent, Qt.Popup | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setFixedWidth(380)
self.queue = []
self.current_index = -1
self._build()
def _build(self):
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 10, 10, 10)
card = QFrame(objectName="queueCard")
shadow = QGraphicsDropShadowEffect(card)
shadow.setBlurRadius(32)
shadow.setOffset(0, 10)
shadow.setColor(QColor(28, 27, 31, 55))
card.setGraphicsEffect(shadow)
outer.addWidget(card)
layout = QVBoxLayout(card)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
header = QHBoxLayout()
header.setContentsMargins(18, 15, 14, 9)
header.setSpacing(8)
title = QLabel("播放队列", objectName="queueTitle")
header.addWidget(title)
self.count = QLabel("0 首", objectName="queueCount")
header.addWidget(self.count)
header.addStretch()
self.clear_btn = QPushButton("清空", objectName="queueClear")
self.clear_btn.setCursor(Qt.PointingHandCursor)
self.clear_btn.setToolTip("清空队列,保留当前播放的歌曲")
self.clear_btn.clicked.connect(self.clear_requested)
header.addWidget(self.clear_btn)
layout.addLayout(header)
self.scroll = QScrollArea(objectName="queueScroll")
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QFrame.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
body = QWidget(objectName="queueBody")
self.rows_layout = QVBoxLayout(body)
self.rows_layout.setContentsMargins(8, 2, 8, 10)
self.rows_layout.setSpacing(1)
self.scroll.setWidget(body)
layout.addWidget(self.scroll, 1)
def set_queue(self, queue, current_index=-1, current_id=None):
self.queue = list(queue or [])
self.current_index = current_index
while self.rows_layout.count():
item = self.rows_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self.count.setText("%d 首" % len(self.queue))
self.clear_btn.setEnabled(bool(self.queue))
scroll_index = -1
if not self.queue:
empty = QLabel("播放队列是空的", objectName="queueEmpty")
empty.setAlignment(Qt.AlignCenter)
self.rows_layout.addWidget(empty)
self.rows_layout.addStretch()
else:
if current_id is not None:
scroll_index = next(
(i for i, song in enumerate(self.queue) if song.get("id") == current_id), -1)
for i, song in enumerate(self.queue):
row = QueueRow(i, song, song.get("id") == current_id)
row.play_requested.connect(self.play_requested)
row.remove_requested.connect(self.remove_requested)
self.rows_layout.addWidget(row)
self.rows_layout.addStretch()
visible_rows = max(1, min(len(self.queue), 7))
self.setFixedHeight(104 + visible_rows * 60)
if scroll_index >= 0:
QTimer.singleShot(0, lambda: self.scroll.verticalScrollBar().setValue(
max(0, (scroll_index - 2) * 60)))
def show_above(self, anchor):
screen = anchor.screen()
geo = screen.availableGeometry()
pos = anchor.mapToGlobal(QPoint(anchor.width(), 0))
x = min(max(geo.left() + 8, pos.x() - self.width()), geo.right() - self.width() - 8)
y = pos.y() - self.height() - 10
if y < geo.top() + 8:
y = geo.top() + 8
self.move(x, y)
self.show()
self.raise_()
class TrackTable(QTableWidget):
play_requested = Signal(list, int)
next_requested = Signal(dict)
COLS = ["", "歌曲", "专辑", "时长", ""]
def __init__(self, parent=None):
super().__init__(0, 5, parent)
self.songs = [] self.songs = []
self.playing_id = None self.playing_id = None
self.covers = {} self.covers = {}
@@ -445,6 +595,8 @@ class TrackTable(QTableWidget):
self.setColumnWidth(2, 190) self.setColumnWidth(2, 190)
header.setSectionResizeMode(3, QHeaderView.Fixed) header.setSectionResizeMode(3, QHeaderView.Fixed)
self.setColumnWidth(3, 72) self.setColumnWidth(3, 72)
header.setSectionResizeMode(4, QHeaderView.Fixed)
self.setColumnWidth(4, 42)
for i, alignment in enumerate((Qt.AlignCenter, Qt.AlignLeft | Qt.AlignVCenter, for i, alignment in enumerate((Qt.AlignCenter, Qt.AlignLeft | Qt.AlignVCenter,
Qt.AlignLeft | Qt.AlignVCenter, Qt.AlignRight | Qt.AlignVCenter)): Qt.AlignLeft | Qt.AlignVCenter, Qt.AlignRight | Qt.AlignVCenter)):
item = self.horizontalHeaderItem(i) item = self.horizontalHeaderItem(i)
@@ -513,8 +665,21 @@ class TrackTable(QTableWidget):
duration.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) duration.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
duration.setForeground(QColor("#aaa59c")) duration.setForeground(QColor("#aaa59c"))
self.setItem(i, 3, duration) self.setItem(i, 3, duration)
more = QTableWidgetItem()
more.setIcon(make_icon("more", "#9d988f", 16))
more.setTextAlignment(Qt.AlignCenter)
more.setToolTip("更多操作")
self.setItem(i, 4, more)
self._paint_row_state(i) self._paint_row_state(i)
self.verticalScrollBar().setValue(0) self.verticalScrollBar().setValue(0)
self.setIconSize(QSize(16, 16))
def _show_more_menu(self, song, position):
menu = QMenu(self)
action = menu.addAction("下一首播放")
action.triggered.connect(lambda: self.next_requested.emit(dict(song)))
menu.exec(position)
menu.deleteLater()
def _paint_row_state(self, row, hover=None): def _paint_row_state(self, row, hover=None):
if not (0 <= row < self.rowCount()): if not (0 <= row < self.rowCount()):
@@ -523,13 +688,14 @@ class TrackTable(QTableWidget):
and self.songs[row].get("id") == self.playing_id) and self.songs[row].get("id") == self.playing_id)
hot = self._hover_row if hover is None else hover hot = self._hover_row if hover is None else hover
color = QColor("#fdf3f1" if current else ("#f5f2ed" if row == hot else "#fffefa")) color = QColor("#fdf3f1" if current else ("#f5f2ed" if row == hot else "#fffefa"))
for col in (0, 2, 3): for col in (0, 1, 2, 3, 4):
item = self.item(row, col) item = self.item(row, col)
if item: if item:
item.setBackground(color) item.setBackground(color)
cell = self.cellWidget(row, 1) cell = self.cellWidget(row, 1)
if cell: if cell:
cell.setStyleSheet("background:%s;" % color.name()) cell.setAttribute(Qt.WA_StyledBackground, False)
cell.setStyleSheet("background:transparent;")
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
row = self.indexAt(event.position().toPoint()).row() row = self.indexAt(event.position().toPoint()).row()
@@ -586,8 +752,13 @@ class TrackTable(QTableWidget):
def _on_click(self, index): def _on_click(self, index):
row = index.row() row = index.row()
if 0 <= row < len(self.songs): if not (0 <= row < len(self.songs)):
self.play_requested.emit(self.songs, row) return
if index.column() == 4:
pos = self.viewport().mapToGlobal(self.visualRect(index).bottomLeft())
self._show_more_menu(self.songs[row], pos)
return
self.play_requested.emit(self.songs, row)
def icon_button(name, size=36, icon_size=18, color="#5f5b55", tooltip=""): def icon_button(name, size=36, icon_size=18, color="#5f5b55", tooltip=""):