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 520 additions and 121 deletions

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
- 标准 / 较高 / 极高 / 无损音质切换
- 逐行歌词、精确居中、点击歌词跳转
- 全屏播放页、黑胶旋转、音量与播放进度控制
- 播放栏显示当前播放歌单
- 播放队列面板:下一首播放、删除单曲、清空保留当前
- 应用内检查更新、自动下载、SHA-256 校验和重启安装
## 开发运行
+79 -77
View File
@@ -7,11 +7,11 @@ import sys
import requests
from PySide6.QtCore import Qt, QUrl, QSize, QTimer, QRunnable, QThreadPool, Signal, QObject
from PySide6.QtGui import QPixmap, QIcon, QColor
from PySide6.QtGui import QPixmap, QIcon, QCursor
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QLineEdit, QStackedWidget, QListWidget, QListWidgetItem, QSlider, QComboBox,
QLineEdit, QStackedWidget, QSlider, QComboBox, QToolTip,
QGridLayout, QSizePolicy, QProgressBar, QDialog,
)
@@ -22,7 +22,7 @@ from version import APP_NAME, APP_VERSION
from theme import QSS, ACCENT
from widgets import (
make_icon, rounded_pixmap, DiscWidget, ClickSlider, LyricView, TrackTable, icon_button,
CoverBackdrop,
CoverBackdrop, QueuePopup,
)
@@ -108,8 +108,6 @@ class MainWindow(QMainWindow):
self.queue = []
self.q_index = -1
self.current = None
self.stack_context_name = "发现音乐"
self.current_playlist_name = ""
self.mode = "list"
self.quality = "exhigh"
self.volume = 78
@@ -230,6 +228,7 @@ class MainWindow(QMainWindow):
self.stack.addWidget(self.charts_page)
self.table = TrackTable()
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.stack.addWidget(self.table)
self.empty_page = self._build_empty_page()
@@ -242,6 +241,11 @@ class MainWindow(QMainWindow):
self.fs.setParent(root)
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):
side = QWidget(objectName="side")
side.setFixedWidth(224)
@@ -281,26 +285,11 @@ class MainWindow(QMainWindow):
layout.addWidget(button)
self.nav_btns[key] = button
queue_header = QHBoxLayout()
queue_header.setContentsMargins(12, 28, 6, 8)
queue_header.addWidget(QLabel("当前队列", objectName="sideSection"))
queue_header.addStretch()
clear = QPushButton("清空", objectName="clearBtn")
clear.setCursor(Qt.PointingHandCursor)
clear.clicked.connect(self._clear_queue)
queue_header.addWidget(clear)
layout.addLayout(queue_header)
self.queue_list = QListWidget(objectName="queue")
self.queue_list.setCursor(Qt.PointingHandCursor)
self.queue_list.viewport().setCursor(Qt.PointingHandCursor)
self.queue_list.itemClicked.connect(self._on_queue_click)
layout.addWidget(self.queue_list, 1)
self.queue_empty = QLabel("播放的歌曲会出现在这里", objectName="queueEmpty")
self.queue_empty.setContentsMargins(12, 4, 8, 8)
layout.addWidget(self.queue_empty)
layout.addSpacing(8)
layout.addStretch()
client_notice = QLabel("客户端暂不提供直链下载", objectName="clientNotice")
client_notice.setContentsMargins(10, 0, 6, 4)
client_notice.setWordWrap(True)
layout.addWidget(client_notice)
version_row = QHBoxLayout()
version_row.setContentsMargins(10, 8, 2, 0)
self.version_label = QLabel("v%s" % APP_VERSION, objectName="versionLabel")
@@ -468,11 +457,8 @@ class MainWindow(QMainWindow):
layout.addLayout(center, 1)
right_widget = QWidget()
right_widget.setFixedWidth(290)
right_column = QVBoxLayout(right_widget)
right_column.setContentsMargins(0, 1, 0, 0)
right_column.setSpacing(4)
right = QHBoxLayout()
right_widget.setFixedWidth(324)
right = QHBoxLayout(right_widget)
right.setContentsMargins(0, 0, 0, 0)
right.setSpacing(7)
right.addStretch()
@@ -502,12 +488,9 @@ class MainWindow(QMainWindow):
self.vol_slider.setToolTip("音量")
self.vol_slider.valueChanged.connect(self._on_volume)
right.addWidget(self.vol_slider)
right_column.addLayout(right)
self.bar_playlist = QLabel("歌单 · 未播放", objectName="barPlaylist")
self.bar_playlist.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.bar_playlist.setFixedHeight(14)
self.bar_playlist.setToolTip("当前播放队列来源")
right_column.addWidget(self.bar_playlist)
self.btn_queue = icon_button("bars", 34, 18, tooltip="播放队列")
self.btn_queue.clicked.connect(self._toggle_queue_popup)
right.addWidget(self.btn_queue)
layout.addWidget(right_widget)
return bar
@@ -629,20 +612,17 @@ class MainWindow(QMainWindow):
self.page_eyebrow.setText("LIBRARY / DISCOVER")
self.page_title.setText("发现音乐")
self.page_meta.setText("网易云音乐官方榜单")
self.stack_context_name = "发现音乐"
self.stack.setCurrentWidget(self.charts_page)
elif key == "search":
self.page_eyebrow.setText("LIBRARY / SEARCH")
self.page_title.setText("搜索")
self.page_meta.setText("在网易云音乐曲库中寻找声音")
self.stack_context_name = "搜索"
self._show_empty("搜索一首歌", "输入歌曲、歌手或专辑名称,按回车开始")
self.search_input.setFocus()
else:
self.page_eyebrow.setText("LIBRARY / HISTORY")
self.page_title.setText("最近播放")
self.page_meta.setText("保存在这台电脑上的播放记录")
self.stack_context_name = "最近播放"
if not self.recent:
self._show_empty("还没有播放记录", "播放过的歌曲会自动保存在这里")
else:
@@ -788,7 +768,6 @@ class MainWindow(QMainWindow):
token = self.view_token
self.page_eyebrow.setText("CHARTS / OFFICIAL")
self.page_title.setText(name)
self.stack_context_name = name
self.btn_back.setVisible(True)
self.btn_refresh.setVisible(False)
self.btn_play_all.setVisible(False)
@@ -877,7 +856,6 @@ class MainWindow(QMainWindow):
)
return
self.page_title.setText(playlist.get("name") or "导入歌单")
self.stack_context_name = playlist.get("name") or "导入歌单"
total = playlist.get("trackCount") or len(songs)
extra = " · 当前导入前 %d 首" % len(songs) if total > len(songs) else ""
self.page_meta.setText("共 %d 首%s · 来自网易云音乐" % (total, extra))
@@ -923,7 +901,6 @@ class MainWindow(QMainWindow):
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)
@@ -950,41 +927,79 @@ class MainWindow(QMainWindow):
# ---------- 队列 ----------
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))
if hasattr(self, "queue_popup") and self.queue_popup.isVisible():
current_id = self.current.get("id") if self.current else None
self.queue_popup.set_queue(self.queue, self.q_index, current_id)
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 _toggle_queue_popup(self):
if self.queue_popup.isVisible():
self.queue_popup.hide()
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):
if self.current is not None:
self.queue = [self.current]
self.q_index = 0
else:
self.queue = []
self.q_index = -1
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):
self.queue = list(songs)
self._set_current_playlist(self.stack_context_name)
self._play_index(index)
if not (0 <= index < len(songs)):
return
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):
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):
@@ -1147,19 +1162,6 @@ class MainWindow(QMainWindow):
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:
+36 -13
View File
@@ -29,15 +29,7 @@ QPushButton#nav {
}
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; }
#clientNotice { color: #65676f; font-size: 9.5px; letter-spacing: .1px; }
#versionLabel { color: #666871; font-size: 9.5px; letter-spacing: .4px; }
QPushButton#versionBtn {
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; }
#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; }
@@ -136,6 +124,41 @@ QComboBox QAbstractItemView {
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; }
#importTitle { color:#22211f; font-size:19px; font-weight:700; }
+223 -20
View File
@@ -5,6 +5,7 @@ import re
import subprocess
import sys
import tempfile
import time
import urllib.parse
import requests
@@ -102,15 +103,169 @@ def _safe_suffix(url):
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()
path = os.path.join(base, "NeteasePlayer", "updates")
if sub:
path = os.path.join(path, sub)
os.makedirs(path, exist_ok=True)
return path
def _quote(path):
return '"' + str(path).replace('"', '""') + '"'
def _ps_quote(value):
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):
@@ -352,8 +507,8 @@ class UpdateDialog(QDialog):
if action == "retry":
self.primary.setProperty("action", "")
self.start_download()
elif action == "install":
self.install()
elif action == "page":
self._open_page()
else:
self.install()
@@ -367,24 +522,72 @@ class UpdateDialog(QDialog):
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:
parent_pid = None
try:
candidate = os.getppid()
if candidate and candidate > 100:
parent_pid = candidate
except Exception: # noqa: BLE001
parent_pid = None
script, log_path = build_install_script(
current, path, os.getpid(), self.info.get("sha256") or "", parent_pid)
# 放在更新目录而不是 Temp,避免脚本尚未启动就被系统清理
script_path = os.path.join(_offset_dir("logs"), "install.ps1")
try:
with open(script_path, "w", encoding="utf-8-sig") as handle:
handle.write(script)
subprocess.Popen(
["cmd.exe", "/c", script_path],
creationflags=0x08000000 | 0x00000008,
except OSError as exc:
self._manual_install(path, "无法写入更新脚本:%s" % exc)
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()
def _notify(self, state, text):
+1 -1
View File
@@ -1,7 +1,7 @@
"""应用版本与更新地址。"""
APP_NAME = "网易云播放器"
APP_VERSION = "1.2.2"
APP_VERSION = "1.2.6"
BUILD_DATE = "2026-10-31"
UPDATE_ENDPOINT = "https://music.yuncishu.xyz/api/app/update"
DOWNLOAD_PAGE = "https://music.yuncishu.xyz/download/"
+180 -9
View File
@@ -2,7 +2,7 @@
import bisect
from PySide6.QtCore import (
Qt, QSize, QRectF, QByteArray, QTimer, QElapsedTimer,
Qt, QSize, QPoint, QRectF, QByteArray, QTimer, QElapsedTimer,
QPropertyAnimation, QEasingCurve, Signal,
)
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 (
QWidget, QLabel, QPushButton, QSlider, QStyle, QScrollArea, QVBoxLayout, QHBoxLayout,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QSizePolicy,
QGraphicsScene, QGraphicsBlurEffect,
QGraphicsScene, QGraphicsBlurEffect, QFrame, QGraphicsDropShadowEffect, QMenu,
)
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"/>',
"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"/>',
"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"/>',
"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"/>',
@@ -404,12 +406,160 @@ class LyricView(QScrollArea):
super().wheelEvent(event)
class TrackTable(QTableWidget):
play_requested = Signal(list, int)
COLS = ["", "歌曲", "专辑", "时长"]
class QueueRow(QWidget):
play_requested = Signal(int)
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):
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.playing_id = None
self.covers = {}
@@ -445,6 +595,8 @@ class TrackTable(QTableWidget):
self.setColumnWidth(2, 190)
header.setSectionResizeMode(3, QHeaderView.Fixed)
self.setColumnWidth(3, 72)
header.setSectionResizeMode(4, QHeaderView.Fixed)
self.setColumnWidth(4, 42)
for i, alignment in enumerate((Qt.AlignCenter, Qt.AlignLeft | Qt.AlignVCenter,
Qt.AlignLeft | Qt.AlignVCenter, Qt.AlignRight | Qt.AlignVCenter)):
item = self.horizontalHeaderItem(i)
@@ -513,8 +665,21 @@ class TrackTable(QTableWidget):
duration.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
duration.setForeground(QColor("#aaa59c"))
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.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):
if not (0 <= row < self.rowCount()):
@@ -523,13 +688,14 @@ class TrackTable(QTableWidget):
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):
for col in (0, 1, 2, 3, 4):
item = self.item(row, col)
if item:
item.setBackground(color)
cell = self.cellWidget(row, 1)
if cell:
cell.setStyleSheet("background:%s;" % color.name())
cell.setAttribute(Qt.WA_StyledBackground, False)
cell.setStyleSheet("background:transparent;")
def mouseMoveEvent(self, event):
row = self.indexAt(event.position().toPoint()).row()
@@ -586,7 +752,12 @@ class TrackTable(QTableWidget):
def _on_click(self, index):
row = index.row()
if 0 <= row < len(self.songs):
if not (0 <= row < len(self.songs)):
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)