老机器救赎:把 Lenovo Q190 改造成音乐基站的全纪录
从零打造 Spotify / 光纤 S/PDIF 独立的 Spotify Content 服务器:避开 Keyring、RDP 与 PipeWire 的硬核踩坑指南
⚠️ 读者必读提醒: 本文中的
playstation是我的系统用户名,192.168.0.241是我这台主机在局域网里的固定 IP。你在跟着操作时,务必将它们替换为你自己的实际用户名和 IP 地址!另外,音频设备 ID(如54或alsa_card.pci-...)每台机器可能不同,请以你本地终端查询出的结果为准。
底盒里掏出了一台几年前的联想(Lenovo)IdeaCentre Q190 Mini-PC。三代低压双核 Intel CPU,外加 可怜的 2GB RAM。直接装个 Windows 11 估计连开机都喘,但扔了又可惜。
仔细盘算了一下:这小东西功耗极低、带千兆网口,背板甚至还藏着一个光纤音频输出口(S/PDIF)!这不就是天生的专职无损音乐播放器(Music Station)吗?
最终我定下的目标是:
- 系统极度轻量:跑 Fedora 桌面环境,但只开必要服务,内存占用压到最低。
- 完全无头(Headless)运行:不接显示器,开机自动进入桌面并静默拉起 Spotify Connect。
- 完美开机体验:开机不显示乱七八糟的英文滚屏,直接显示联想 Lenovo Logo 加转圈圈,跟原厂 Workstation 一模一样。
- 数字光纤输出:绕过板载廉价 DAC,把无损数字信号送到外置解码器和功放。
- 小白友好 Web 控制台:写个轻量级网页控制台,手机随时点按调音量、切线路,彻底抛弃命令行。
本以为半小时就能搞定,结果在 GNOME Keyring 密钥环、RDP 远程桌面和 PipeWire 声卡配置上踩遍了 Linux 的各种神仙坑。下面就把这趟“折腾血泪史”完整整理出来。
一、 系统底座:Fedora Minimal 安装与资源瘦身
为了在 2GB 内存的“矮子”里拔将军,千万不要直接用 Fedora Workstation 的官方 ISO。我选择的是 Fedora Everything ISO,安装时选择 Minimal Install(最小化安装)。
安装完成后,先补全最基础的 GNOME 图形核心和 RDP 远程桌面支持:
sudo dnf install -y gnome-shell gdm gnome-remote-desktop NetworkManager-adwaita
为了防止 Tracker 这种内存大户在后台偷偷吃光内存,顺手做一下瘦身,并开启 zram 内存压缩(Fedora 默认已带):
# 开启图形界面为默认启动目标
sudo systemctl set-default graphical.target
二、 自动登录与 GNOME Keyring 的“死锁惨案”
我们要实现 Headless(不接显示器),所以开机必须自动登录进桌面,否则 GNOME Remote Desktop (RDP) 和 Spotify 根本不会在后台拉起。
1. 配置 GDM 自动登录
编辑 GDM 配置文件:
sudo nano /etc/gdm/custom.conf
在 [daemon] 下面加入这两行(注意替换用户名):
[daemon]
AutomaticLoginEnable=True
AutomaticLogin=playstation
2. 踩坑:grdctl 卡死与密钥环死锁
本以为配置完自动登录就能愉快地用 grdctl 开启 RDP 了,结果输入命令:
grdctl rdp set-credentials playstation playstation
终端直接提示:Couldn't create item: Cannot set secret of a locked item,甚至直接卡死没有响应!
【原理浅析】:为什么会卡死?因为你开启了“开机自动登录”,系统绕过了手动输入密码的步骤,导致后台保存密码的 GNOME Keyring(系统密钥环) 处于锁定状态。grdctl 想要把 RDP 密码写入密钥环,后台便弹出了一个隐藏的密码确认框,终端在死等 D-Bus 响应,于是就死锁了。
3. 终极大坑:无限循环登录(无限转圈圈)
当时我脑子一热,直接在终端里删除了密钥环目录(rm -rf ~/.local/share/keyrings)。结果重启后直接崩塌——GNOME 登录界面陷入无限循环,输入密码回车就黑屏转圈,随后又弹回登录框!
【原理浅析】:强行删除密钥环或用 sudo 乱动配置文件,极易导致 /home/用户名 目录下的隐藏文件夹权限变成 root,或者让桌面缓存损坏。GNOME 启动时写不了配置文件,就会瞬间崩溃。
🚑 救援步骤(如果你也遇到了无限转圈):
- 在卡住的界面,按下组合键 **
Ctrl+Alt+F3**切换到 TTY3 纯命令行终端。 - 输入用户名密码登录,运行以下命令抢回家目录的所有权并重置权限:
# 1. 把家目录下的所有权强制还给你的普通用户(替换 playstation)
sudo chown -R playstation:playstation /home/playstation
# 2. 重新创建干净的 keyrings 目录并赋予正确权限
rm -rf ~/.local/share/keyrings
mkdir -p ~/.local/share/keyrings
chmod 700 ~/.local/share/keyrings
chown -R playstation:playstation ~/.local/share/keyrings
# 3. 清理损坏的桌面缓存
rm -rf ~/.cache/*
# 4. 重启图形界面服务
sudo systemctl restart gdm
4. 正确的“免密解锁密钥环”解决方案
想让自动登录和密钥环和谐共处,优雅的做法是将密钥环密码设为“空”:
- 成功进入桌面后,安装密钥管理工具:
sudo dnf install -y seahorse
- 在应用列表搜索打开 “密码和密钥 (Passwords and Keys)”。
- 在左侧找到 “登录 (login / Default keyring)”,右键点击 “更改密码”。
- 输入旧密码后,在设置新密码弹窗中保持完全留空,点击“继续”,并在弹出的警告中选择 “使用未加密的存储”。
这样处理后,Q190 每次开机既能自动登录,后台的 RDP 和密钥环也不会再被锁定了!
三、 配置 RDP 远程桌面与 Spotify 自启
解决完密钥环问题,RDP 配置就极其顺畅了。
1. 开启 RDP 服务与防火墙
在终端中以普通用户身份(不要加 sudo) 运行:
# 启动用户级远程桌面服务
systemctl --user enable --now gnome-remote-desktop
# 设置 RDP 账号密码(替换 playstation 为你的账号密码)
grdctl rdp set-credentials playstation playstation
grdctl rdp enable
# 防火墙放行 RDP 3389 端口
sudo firewall-cmd --add-port=3389/tcp --permanent
sudo firewall-cmd --reload
此时在另一台电脑上用 Remmina 或 Windows 远程桌面连接 192.168.0.241,就能流畅看到桌面了。
2. Flatpak 安装 Spotify 与自动全屏
通过 Flatpak 安装 Spotify:
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install flathub com.spotify.Client -y
为了让 Spotify 开机自动拉起并最大化,我们需要创建一个自启配置文件。
⚠️ 踩坑提醒:最小化安装的系统默认是没有
autostart目录的!直接nano保存会报错No such file or directory。必须先手动创建目录!
# 1. 必须先创建 autostart 目录
mkdir -p ~/.config/autostart
# 2. 创建自启配置文件
nano ~/.config/autostart/spotify.desktop
粘贴以下内容并保存:
[Desktop Entry]
Type=Application
Name=Spotify
Exec=flatpak run com.spotify.Client --start-maximized
X-GNOME-Autostart-enabled=true
四、 还原 Workstation 级开机体验:优雅的 Plymouth Lenovo Logo
最小化安装的 Fedora 开机时会刷过几百行滚屏英文日志,显得非常工业。我们要把它改造成和正版系统一样的高大上黑底 Lenovo Logo + 旋转加载圈。
1. 安装 Plymouth 官方主题
Plymouth 的 bgrt 主题会自动读取 Q190 主板固件里的 Lenovo 原厂 Logo:
# 安装开机动画组件
sudo dnf install -y plymouth plymouth-theme-spinner plymouth-system-theme
# 将默认主题设为 bgrt(主板品牌 Logo)
sudo plymouth-set-default-theme bgrt -R
2. 开启内核静默启动(消除英文滚屏)
使用 Fedora 的 grubby 工具给内核加上 rhgb(图形化 Boot)和 quiet(静默)参数:
sudo grubby --update-kernel=ALL --args="rhgb quiet"
3. 隐藏 GRUB 启动选择菜单
修改 GRUB 配置文件:
sudo nano /etc/default/grub
确保包含以下两行(设倒计时为 0 并隐藏):
GRUB_TIMEOUT=0
GRUB_TIMEOUT_STYLE=hidden
更新 GRUB 引导配置:
sudo grub2-editenv - set menu_auto_hide=1
sudo grub2-mkconfig -o /boot/grub2/grub.cfg
重启之后,原本嘈杂的内核日志瞬间消失,取而代之的是极致干净的 Lenovo 品牌 Logo!
五、 激活隐藏的光纤接口 (S/PDIF / IEC958)
Lenovo Q190 背面蓝色的 VGA 接口旁边,藏着一个方形的 TOSLINK 光纤音频输出口(平时里面插着一个小黑色的塑料防尘塞,拔掉即可)。
把光纤线接上解码器后,你会发现系统里根本找不到光纤输出设备!
【原理浅析】:光纤 TOSLINK 接口与 3.5mm 耳机孔不同,它没有任何物理插拔检测机制。Linux 并不知道你插上了光纤线,且 PipeWire/ALSA 默认会把光纤通道(IEC958)设为静音或隐藏状态。
1. 补全音频命令行工具
sudo dnf install -y alsa-utils pulseaudio-utils
2. 解除 ALSA 底层静音
- 在终端运行:
alsamixer - 按
F6选择你的板载声卡(如HDA Intel PCH,不要选 HDMI)。 - 用右方向键
→找到带有IEC958的选项。 - 如果下方显示
[MM]说明当前是静音状态,按下键盘M键,使其变成[00](绿色解除静音)。 - 按
Esc退出。
3. 强制切换声卡配置为光纤模式
查找你的声卡全名:
pactl list cards | grep -E 'Name:|Active Profile:'
在输出中找到类似 alsa_card.pci-0000_00_1b.0 的名字,然后运行命令,将 Profile 强制切到光纤数字输出模式:
pactl set-card-profile alsa_card.pci-0000_00_1b.0 output:iec958-stereo
切换成功后,查看 PipeWire 设备列表:
wpctl status
你会看到 Sinks 列表里多出了 Built-in Audio Digital Stereo (IEC958)(假设编号为 54)。将其设为默认输出:
wpctl set-default 54
此时凑近看一下 Q190 背面的光纤孔,内部已经亮起了微弱且治愈的红光!
六、 打造专属 Python Web 音频控制台
天天敲命令行切音频太痛苦了。为了能在手机上随时调音量、切线路、连蓝牙,我用 Python Flask 写了一个极轻量的 Web 控制台,内存占用仅 15MB。
1. 编写 Web 控制台程序
安装 Flask:
sudo dnf install -y python3-flask
创建代码目录并编写后端程序:
mkdir -p ~/music-web
nano ~/music-web/app.py
粘贴以下 Python 完整源码:
import subprocess
import json
import os
import time
from flask import Flask, render_template_string, request, jsonify
app = Flask(__name__)
CARD_NAME = "alsa_card.pci-0000_00_1b.0"
def get_env():
env = os.environ.copy()
uid = os.getuid()
if 'DBUS_SESSION_BUS_ADDRESS' not in env:
env['DBUS_SESSION_BUS_ADDRESS'] = f"unix:path=/run/user/{uid}/bus"
if 'XDG_RUNTIME_DIR' not in env:
env['XDG_RUNTIME_DIR'] = f"/run/user/{uid}"
return env
def run_cmd(cmd):
try:
res = subprocess.run(cmd, shell=True, capture_output=True, text=True, env=get_env())
return res.stdout.strip()
except Exception as e:
return str(e)
# 终极修复:软件+硬件级双重暴力拦截光纤/PCM镜像输出
def toggle_optical_hardware(enable=True):
state = "on" if enable else "off"
mute_num = "0" if enable else "1"
# 1. 软件级:遍历 PipeWire 现存的全部光纤/数字节点,强制静音
sinks = run_cmd("pactl list sinks short")
for line in sinks.splitlines():
if 'iec958' in line.lower() or 'spdif' in line.lower() or 'digital' in line.lower():
try:
sink_id = line.split()[0]
run_cmd(f"pactl set-sink-mute {sink_id} {mute_num}")
except: pass
# 2. 硬件级:穷举所有可能的 ALSA S/PDIF 硬件开关别名,全声卡无死角拉闸
cmds = []
controls = [
"IEC958", "IEC958,0", "IEC958,1", "IEC958,2",
"IEC958 Default PCM", "IEC958 Playback Switch",
"S/PDIF", "S/PDIF,0", "S/PDIF,1", "S/PDIF Default PCM",
"SPDIF", "SPDIF Default PCM", "SPDIF,0", "Optical"
]
for card_idx in [0, 1, 2]:
for c in controls:
cmds.append(f"amixer -c {card_idx} sset '{c}' {state} 2>/dev/null")
cmds.append(f"amixer -c {card_idx} set '{c}' {state} 2>/dev/null")
# 瞬间并发执行,不阻塞网页
batch_cmd = " ; ".join(cmds)
subprocess.Popen(batch_cmd, shell=True, env=get_env())
DEFAULT_COVER = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='300' height='300' viewBox='0 0 300 300'><rect width='300' height='300' fill='%23161b22'/><circle cx='150' cy='150' r='60' fill='none' stroke='%2330363d' stroke-width='8'/><path d='M138 125 L175 150 L138 175 Z' fill='%230070d1'/></svg>"
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlayStation Console</title>
<style>
:root {
--bg-color: #0b0e14;
--card-bg: rgba(22, 27, 34, 0.75);
--card-border: rgba(255, 255, 255, 0.08);
--text-main: #f0f6fc;
--text-sub: #8b949e;
--optical-glow: #ff2a5f;
--optical-grad: linear-gradient(135deg, #ff2a5f, #ff6b00);
--hdmi-glow: #0070d1;
--hdmi-grad: linear-gradient(135deg, #0070d1, #00c6ff);
--analog-glow: #a855f7;
--analog-grad: linear-gradient(135deg, #a855f7, #ec4899);
--usb-glow: #10b981;
--usb-grad: linear-gradient(135deg, #10b981, #059669);
}
* { box-sizing: border-box; transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg-color);
background-image:
radial-gradient(at 0% 0%, rgba(0, 112, 209, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(255, 42, 95, 0.1) 0px, transparent 50%);
color: var(--text-main);
margin: 0; padding: 20px 16px;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#loading-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: #0b0e14; display: flex; flex-direction: column;
justify-content: center; align-items: center; z-index: 9999;
transition: opacity 0.5s ease, visibility 0.5s ease;
}
.loader-title {
font-size: 1.8rem; font-weight: 800; margin-bottom: 20px;
background: linear-gradient(135deg, #ffffff 30%, #0070d1 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.progress-bar-container {
width: 220px; height: 6px; background: rgba(255, 255, 255, 0.1);
border-radius: 3px; overflow: hidden;
}
.progress-bar-fill {
width: 0%; height: 100%;
background: linear-gradient(90deg, #0070d1, #00c6ff);
transition: width 0.4s ease; box-shadow: 0 0 12px #0070d1;
}
header { text-align: center; margin-bottom: 16px; width: 100%; max-width: 900px; }
h1 {
font-size: 2.2rem; font-weight: 800; letter-spacing: -0.5px; margin: 0 0 2px 0;
background: linear-gradient(135deg, #ffffff 30%, #0070d1 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.subtitle { font-size: 0.8rem; color: var(--text-sub); letter-spacing: 2px; text-transform: uppercase; }
.dashboard {
display: grid; grid-template-columns: 1fr; gap: 20px; width: 100%;
max-width: 900px; opacity: 0; transform: translateY(15px);
}
.dashboard.loaded { opacity: 1; transform: translateY(0); transition: opacity 0.5s ease, transform 0.5s ease; }
@media (min-width: 768px) {
.dashboard { grid-template-columns: 340px 1fr; }
}
.left-col { display: flex; flex-direction: column; }
.right-col { display: flex; flex-direction: column; gap: 16px; }
.card {
background: var(--card-bg); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); border: 1px solid var(--card-border);
border-radius: 24px; padding: 20px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
position: relative;
}
.card-title {
font-size: 0.85rem; font-weight: 700; color: var(--text-sub);
text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 14px;
display: flex; align-items: center; justify-content: space-between;
}
.now-playing-card { height: 100%; display: flex; flex-direction: column; justify-content: space-between; }
.album-art-wrap {
width: 100%; aspect-ratio: 1 / 1; border-radius: 18px; margin-bottom: 16px; position: relative;
}
.album-art-bg {
position: absolute; top: 6px; left: 6px; width: 96%; height: 96%;
object-fit: cover; border-radius: 18px;
filter: blur(22px) opacity(0.6) brightness(1.1);
z-index: 1; transition: opacity 0.5s ease, filter 0.5s ease;
}
.album-art {
position: relative; z-index: 2; width: 100%; height: 100%;
object-fit: cover; border-radius: 18px; box-shadow: 0 8px 20px rgba(0,0,0,0.4);
}
.now-playing-card.is-playing .album-art-bg {
animation: dynamicBreathing 3.5s infinite alternate ease-in-out;
}
.track-info { text-align: center; margin-bottom: 16px; }
.track-title {
font-weight: 800; font-size: 1.2rem; color: var(--text-main);
margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.track-artist { font-size: 0.88rem; color: var(--text-sub); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.player-controls { display: flex; justify-content: center; align-items: center; gap: 18px; }
.ctrl-btn {
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%; width: 48px; height: 48px; display: flex; align-items: center;
justify-content: center; font-size: 1.2rem; cursor: pointer; color: var(--text-main);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.ctrl-btn:hover { background: rgba(255, 255, 255, 0.18); transform: translateY(-2px) scale(1.08); }
.ctrl-btn:active { transform: scale(0.92); }
.ctrl-btn.play-pause {
background: var(--hdmi-grad); border: none; width: 58px; height: 58px; font-size: 1.4rem;
box-shadow: 0 0 20px rgba(0, 112, 209, 0.5);
}
.mode-grid { display: flex; flex-direction: column; gap: 10px; }
.mode-btn {
background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px; padding: 13px 18px; color: var(--text-main); font-size: 0.92rem;
font-weight: 600; display: flex; align-items: center; justify-content: space-between; cursor: pointer;
}
.mode-btn:hover { transform: translateX(4px); background: rgba(255, 255, 255, 0.07); }
.mode-btn:active { transform: scale(0.98); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255, 255, 255, 0.2); }
.mode-btn[data-mode="optical"].active {
background: var(--optical-grad); border-color: var(--optical-glow);
box-shadow: 0 0 20px rgba(255, 42, 95, 0.5);
}
.mode-btn[data-mode="hdmi"].active {
background: var(--hdmi-grad); border-color: var(--hdmi-glow);
box-shadow: 0 0 20px rgba(0, 112, 209, 0.5);
}
.mode-btn[data-mode="analog"].active {
background: var(--analog-grad); border-color: var(--analog-glow);
box-shadow: 0 0 20px rgba(168, 85, 247, 0.5);
}
.mode-btn[data-mode="usb"].active {
background: var(--usb-grad); border-color: var(--usb-glow);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.5);
}
.mode-btn.active .status-dot { background: #fff; box-shadow: 0 0 10px #fff; }
.volume-container { display: flex; align-items: center; gap: 14px; margin: 8px 0; }
input[type=range] {
flex: 1; height: 8px; border-radius: 4px;
background: linear-gradient(to right, #0070d1 0%, #0070d1 50%, rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.1) 100%);
appearance: none; outline: none; cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
appearance: none; width: 22px; height: 22px; border-radius: 50%; background: #fff;
box-shadow: 0 0 12px rgba(0, 112, 209, 0.9); cursor: pointer;
}
.vol-display { font-size: 1.1rem; font-weight: 700; min-width: 48px; text-align: right; color: #0070d1; }
.action-btn {
width: 100%; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px; padding: 11px; color: var(--text-main); font-weight: 600; cursor: pointer; margin-top: 4px;
}
.action-btn:hover { background: rgba(255, 255, 255, 0.12); }
.device-item { background: rgba(0, 0, 0, 0.25); border-radius: 12px; padding: 10px 14px; margin: 8px 0; display: flex; justify-content: space-between; align-items: center; }
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0, 0, 0, 0.75); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
display: flex; justify-content: center; align-items: center; z-index: 9999;
opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease;
}
.modal-overlay.active { opacity: 1; visibility: visible; }
.modal-card {
background: rgba(26, 32, 44, 0.95); border: 1px solid var(--card-border);
border-radius: 24px; padding: 26px; width: 90%; max-width: 420px;
box-shadow: 0 20px 50px rgba(0,0,0,0.6); text-align: center;
transform: scale(0.9); transition: transform 0.3s ease;
}
.modal-overlay.active .modal-card { transform: scale(1); }
.modal-title { font-size: 1.15rem; font-weight: 800; margin-bottom: 10px; color: var(--text-main); }
.modal-desc { font-size: 0.9rem; color: var(--text-sub); line-height: 1.5; margin-bottom: 18px; }
.modal-actions { display: flex; gap: 12px; }
@keyframes dynamicBreathing {
0% { filter: blur(20px) opacity(0.4) scale(0.94); }
100% { filter: blur(28px) opacity(0.8) scale(1.03); }
}
</style>
</head>
<body>
<div id="loading-overlay">
<div class="loader-title">PlayStation Console</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progressFill"></div>
</div>
</div>
<div id="modal-overlay" class="modal-overlay">
<div class="modal-card">
<div id="modal-step-confirm" style="display: none;">
<div class="modal-title">↺ 重启 Spotify Connect</div>
<div class="modal-desc">确定要重新拉起后台 Spotify 服务吗?<br>当前播放可能会短暂中断。</div>
<div class="modal-actions">
<button class="action-btn" style="background: rgba(255,255,255,0.08);" onclick="closeModal()">取消</button>
<button class="action-btn" style="background: var(--hdmi-grad); border:none;" onclick="startRestartProcess()">确认重启</button>
</div>
</div>
<div id="modal-step-progress" style="display: none;">
<div class="modal-title">↻ 正在重启服务...</div>
<div class="modal-desc" id="restartCountdownText">请稍候,预计需要 10 秒</div>
<div class="progress-bar-container" style="width: 100%; height: 8px; margin-top: 10px;">
<div class="progress-bar-fill" id="restartProgressFill" style="transition: width 1s linear;"></div>
</div>
</div>
<div id="modal-step-vol-warning" style="display: none;">
<div class="modal-title" style="color: #ff2a5f;">⚠️ 听力保护提醒</div>
<div class="modal-desc">将音量调至 60% 以上可能会对听力造成损害。<br>确定要继续调高音量吗?</div>
<div class="modal-actions">
<button class="action-btn" style="background: rgba(255,255,255,0.08);" onclick="cancelHighVolume()">保持 60%</button>
<button class="action-btn" style="background: var(--optical-grad); border:none;" onclick="confirmHighVolume()">确定调高</button>
</div>
</div>
<div id="modal-step-usb" style="display: none;">
<div class="modal-title">🔌 接入的 USB 音频设备 / DAC</div>
<div class="modal-desc" id="usbScanStatus">正在检索可用的外置 USB 声卡...</div>
<div id="usbDeviceList" style="max-height: 220px; overflow-y: auto; text-align: left; margin-bottom: 15px;"></div>
<div class="modal-actions">
<button class="action-btn" style="background: rgba(255,255,255,0.08);" onclick="closeModal()">关闭</button>
<button class="action-btn" style="background: var(--usb-grad); border:none;" onclick="scanUsbDevices()">🔄 重新扫描</button>
</div>
</div>
</div>
</div>
<header>
<h1>PlayStation Console</h1>
<div class="subtitle">Audio & Hardware Station</div>
</header>
<div class="dashboard" id="mainDashboard">
<div class="left-col">
<div class="card now-playing-card" id="nowPlayingCard">
<div>
<div class="card-title">
<span>🎵 正在播放</span>
<span id="playerStatus" style="font-size:0.75rem; color:var(--text-sub);">D-Bus Active</span>
</div>
<div class="album-art-wrap">
<img id="albumArtBg" class="album-art-bg" src="{{ DEFAULT_COVER }}" alt="Album Glow">
<img id="albumArt" class="album-art" src="{{ DEFAULT_COVER }}" alt="Album Cover">
</div>
<div class="track-info">
<div id="trackTitle" class="track-title">未在播放歌曲</div>
<div id="trackArtist" class="track-artist">Spotify Connect</div>
</div>
</div>
<div class="player-controls">
<button class="ctrl-btn" onclick="controlPlayer('previous')">⏮</button>
<button class="ctrl-btn play-pause" id="playPauseBtn" onclick="controlPlayer('playpause')">▶</button>
<button class="ctrl-btn" onclick="controlPlayer('next')">⏭</button>
</div>
</div>
</div>
<div class="right-col">
<div class="card">
<div class="card-title">🔊 音频输出线路切换</div>
<div class="mode-grid">
<button class="mode-btn" data-mode="optical" onclick="setMode('optical')">
<span>✨ 光纤数字输出 (S/PDIF)</span>
<span class="status-dot"></span>
</button>
<button class="mode-btn" data-mode="hdmi" onclick="setMode('hdmi')">
<span>📺 HDMI 数字输出</span>
<span class="status-dot"></span>
</button>
<button class="mode-btn" data-mode="analog" onclick="setMode('analog')">
<span>🎧 3.5mm 模拟耳机口</span>
<span class="status-dot"></span>
</button>
<button class="mode-btn" data-mode="usb" onclick="openUsbModal()">
<span>🔌 USB 通用设备 (DAC/外置声卡)</span>
<span class="status-dot"></span>
</button>
</div>
</div>
<div class="card">
<div class="card-title">🎚 主音量控制</div>
<div class="volume-container">
<span id="volIcon" style="font-size: 1.3rem;">🔊</span>
<input type="range" id="volumeSlider" min="0" max="100" oninput="onVolumeInput(this.value)" onchange="setVolume(this.value)">
<span class="vol-display" id="volVal">--%</span>
</div>
<button class="action-btn" onclick="toggleMute()">静音 / 恢复</button>
</div>
<div class="card">
<div class="card-title">📶 蓝牙连接</div>
<button class="action-btn" onclick="scanBluetooth()">🔍 扫描周边蓝牙音箱</button>
<div id="btList" style="margin-top: 8px; font-size: 0.85rem; color: var(--text-sub);"></div>
</div>
<div class="card">
<div class="card-title">🚀 快捷服务操作</div>
<button class="action-btn" onclick="openRestartModal()">↺ 重启 Spotify Connect</button>
</div>
</div>
</div>
<script>
const DEFAULT_COVER = "{{ DEFAULT_COVER }}";
let isMuted = false;
let actualVolume = 50;
let pendingHighVolume = null;
function updateSliderTrack(val) {
const slider = document.getElementById('volumeSlider');
slider.style.background = `linear-gradient(to right, #0070d1 0%, #0070d1 ${val}%, rgba(255, 255, 255, 0.1) ${val}%, rgba(255, 255, 255, 0.1) 100%)`;
}
function updateVolumeUI(vol, muted) {
isMuted = muted;
actualVolume = vol;
const slider = document.getElementById('volumeSlider');
const volVal = document.getElementById('volVal');
const volIcon = document.getElementById('volIcon');
if (isMuted) {
slider.value = 0;
volVal.innerText = '0%';
volIcon.innerText = '🔇';
updateSliderTrack(0);
} else {
slider.value = actualVolume;
volVal.innerText = actualVolume + '%';
volIcon.innerText = actualVolume > 50 ? '🔊' : '🔈';
updateSliderTrack(actualVolume);
}
}
function onVolumeInput(val) {
const volVal = document.getElementById('volVal');
const volIcon = document.getElementById('volIcon');
updateSliderTrack(val);
if (val == 0) {
volVal.innerText = '0%';
volIcon.innerText = '🔇';
} else {
volVal.innerText = val + '%';
volIcon.innerText = val > 50 ? '🔊' : '🔈';
}
}
function fetchVolume() {
return fetch('/api/volume').then(r => r.json()).then(data => {
updateVolumeUI(data.volume, data.is_muted);
});
}
function setVolume(val) {
val = parseInt(val);
if (val > 60) {
pendingHighVolume = val;
openVolumeWarningModal();
} else {
commitVolume(val);
}
}
function commitVolume(val) {
fetch('/api/volume', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({volume: val})
}).then(() => fetchVolume());
}
function openVolumeWarningModal() {
hideAllModalSteps();
document.getElementById('modal-step-vol-warning').style.display = 'block';
document.getElementById('modal-overlay').classList.add('active');
}
function confirmHighVolume() {
if (pendingHighVolume !== null) {
commitVolume(pendingHighVolume);
pendingHighVolume = null;
}
closeModal();
}
function cancelHighVolume() {
pendingHighVolume = null;
commitVolume(60);
closeModal();
}
function fetchMode() {
return fetch('/api/mode').then(r => r.json()).then(data => {
highlightMode(data.mode);
});
}
function fetchPlayer() {
return fetch('/api/spotify/player').then(r => r.json()).then(data => {
const titleElem = document.getElementById('trackTitle');
const artistElem = document.getElementById('trackArtist');
const artElem = document.getElementById('albumArt');
const artBgElem = document.getElementById('albumArtBg');
const cardElem = document.getElementById('nowPlayingCard');
if (titleElem.innerText !== data.title) {
titleElem.innerText = data.title;
artistElem.innerText = data.artist;
artElem.style.opacity = '0.2';
artBgElem.style.opacity = '0.2';
setTimeout(() => {
const targetArt = data.art_url || DEFAULT_COVER;
artElem.src = targetArt;
artBgElem.src = targetArt;
artElem.style.opacity = '1';
artBgElem.style.opacity = '0.6';
}, 200);
}
document.getElementById('playerStatus').innerText = data.status;
document.getElementById('playPauseBtn').innerText = (data.status === 'Playing') ? '⏸' : '▶';
if (data.status === 'Playing') {
cardElem.classList.add('is-playing');
} else {
cardElem.classList.remove('is-playing');
}
});
}
function highlightMode(activeMode) {
document.querySelectorAll('.mode-btn').forEach(btn => {
if (btn.getAttribute('data-mode') === activeMode) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
function setMode(mode) {
highlightMode(mode);
fetch('/api/mode', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mode: mode})
});
}
function openUsbModal() {
hideAllModalSteps();
document.getElementById('modal-step-usb').style.display = 'block';
document.getElementById('modal-overlay').classList.add('active');
scanUsbDevices();
}
function scanUsbDevices() {
const listElem = document.getElementById('usbDeviceList');
const statusElem = document.getElementById('usbScanStatus');
statusElem.innerText = "正在检索 USB 音频设备...";
listElem.innerHTML = '';
fetch('/api/usb/scan').then(r => r.json()).then(devices => {
if (!devices || devices.length === 0) {
statusElem.innerText = "未检测到 USB DAC 或外置声卡。";
listElem.innerHTML = '<div style="text-align:center; color: var(--text-sub); padding:15px; font-size:0.85rem;">请确认 USB 音频设备已插紧至 Q190 接口</div>';
return;
}
statusElem.innerText = `检测到 ${devices.length} 个可用 USB 音频设备:`;
let html = '';
devices.forEach(d => {
html += `<div class="device-item">
<span style="font-size:0.88rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:230px;"><b>${d.desc}</b></span>
<button class="action-btn" style="width:auto; margin:0; padding:6px 14px; background: var(--usb-grad); border:none;" onclick="selectUsbDevice('${d.name}')">选择</button>
</div>`;
});
listElem.innerHTML = html;
});
}
function selectUsbDevice(sinkName) {
fetch('/api/usb/set', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({sink: sinkName})
}).then(r => r.json()).then(() => {
highlightMode('usb');
closeModal();
});
}
function toggleMute() {
fetch('/api/mute', {method: 'POST'}).then(() => fetchVolume());
}
function controlPlayer(action) {
fetch('/api/spotify/control', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: action})
}).then(() => setTimeout(fetchPlayer, 250));
}
function scanBluetooth() {
document.getElementById('btList').innerText = "正在扫描中...";
fetch('/api/bluetooth/scan').then(r => r.json()).then(devices => {
let html = '';
devices.forEach(d => {
html += `<div class="device-item">
<span><b>${d.name}</b></span>
<button class="action-btn" style="width:auto; margin:0; padding:6px 12px;" onclick="connectBt('${d.mac}')">连接</button>
</div>`;
});
document.getElementById('btList').innerHTML = html || "未搜索到设备。";
});
}
function connectBt(mac) {
fetch('/api/bluetooth/connect', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mac: mac})
}).then(r => r.json()).then(data => alert(data.msg));
}
function hideAllModalSteps() {
document.getElementById('modal-step-confirm').style.display = 'none';
document.getElementById('modal-step-progress').style.display = 'none';
document.getElementById('modal-step-vol-warning').style.display = 'none';
document.getElementById('modal-step-usb').style.display = 'none';
}
function openRestartModal() {
hideAllModalSteps();
document.getElementById('modal-step-confirm').style.display = 'block';
document.getElementById('modal-overlay').classList.add('active');
}
function closeModal() {
if (document.getElementById('modal-step-vol-warning').style.display === 'block' && pendingHighVolume !== null) {
cancelHighVolume();
return;
}
document.getElementById('modal-overlay').classList.remove('active');
}
function startRestartProcess() {
hideAllModalSteps();
document.getElementById('modal-step-progress').style.display = 'block';
const fill = document.getElementById('restartProgressFill');
const txt = document.getElementById('restartCountdownText');
fill.style.width = '0%';
let seconds = 10;
fetch('/api/spotify/restart', {method: 'POST'});
const timer = setInterval(() => {
seconds--;
const percent = ((10 - seconds) / 10) * 100;
fill.style.width = percent + '%';
if (seconds > 0) {
txt.innerText = `服务拉起中,剩余 ${seconds} 秒...`;
} else {
clearInterval(timer);
txt.innerText = '🎉 重启完成!';
setTimeout(() => {
document.getElementById('modal-overlay').classList.remove('active');
fetchPlayer();
}, 800);
}
}, 1000);
}
window.addEventListener('DOMContentLoaded', () => {
const fill = document.getElementById('progressFill');
const overlay = document.getElementById('loading-overlay');
const dash = document.getElementById('mainDashboard');
fill.style.width = '45%';
Promise.all([fetchVolume(), fetchMode(), fetchPlayer()]).then(() => {
fill.style.width = '100%';
setTimeout(() => {
overlay.style.opacity = '0';
overlay.style.visibility = 'hidden';
dash.classList.add('loaded');
}, 400);
}).catch(() => {
fill.style.width = '100%';
overlay.style.opacity = '0';
overlay.style.visibility = 'hidden';
dash.classList.add('loaded');
});
setInterval(fetchPlayer, 2000);
});
</script>
</body>
</html>
"""
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE, DEFAULT_COVER=DEFAULT_COVER)
@app.route('/api/volume', methods=['GET'])
def get_volume():
out = run_cmd("wpctl get-volume @DEFAULT_AUDIO_SINK@")
vol = 50
is_muted = "[MUTED]" in out
if "Volume:" in out:
try:
vol = int(float(out.split()[1]) * 100)
except:
pass
return jsonify({'volume': vol, 'is_muted': is_muted})
@app.route('/api/volume', methods=['POST'])
def set_volume():
data = request.get_json()
vol = data.get('volume', 50)
run_cmd(f"wpctl set-volume @DEFAULT_AUDIO_SINK@ {vol}%")
run_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ 0")
return jsonify({'status': 'ok'})
@app.route('/api/mute', methods=['POST'])
def toggle_mute():
run_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle")
return jsonify({'status': 'ok'})
@app.route('/api/mode', methods=['GET'])
def get_mode():
def_sink = run_cmd("pactl get-default-sink")
if "usb" in def_sink.lower() or "dac" in def_sink.lower():
return jsonify({'mode': 'usb'})
out = run_cmd("pactl list cards")
if "output:iec958-stereo" in out and "Active Profile: output:iec958" in out:
mode = "optical"
elif "output:hdmi-stereo" in out and "Active Profile: output:hdmi" in out:
mode = "hdmi"
else:
mode = "analog"
return jsonify({'mode': mode})
@app.route('/api/mode', methods=['POST'])
def set_mode():
mode = request.get_json().get('mode')
# 1. 软件层级:先切换 PipeWire Profile
if mode == 'optical':
run_cmd(f"pactl set-card-profile {CARD_NAME} output:iec958-stereo")
elif mode == 'hdmi':
run_cmd(f"pactl set-card-profile {CARD_NAME} output:hdmi-stereo")
elif mode == 'analog':
run_cmd(f"pactl set-card-profile {CARD_NAME} output:analog-stereo")
# 稍作延时,让 PipeWire 完成节点重置
time.sleep(0.3)
# 2. 硬件层级:精准且暴力拦截光纤/PCM镜像
if mode == 'optical':
toggle_optical_hardware(True)
else:
toggle_optical_hardware(False)
# 3. 软件层级:重选默认下发通道(防止被锁在其他设备)
if mode in ['optical', 'hdmi', 'analog']:
sinks_out = run_cmd("pactl list sinks short")
for line in sinks_out.splitlines():
if "00_1b.0" in line:
parts = line.split()
if len(parts) >= 2:
sink_name = parts[1]
if mode == 'optical' and 'iec958' in sink_name:
run_cmd(f"pactl set-default-sink {sink_name}")
break
elif mode == 'hdmi' and 'hdmi' in sink_name:
run_cmd(f"pactl set-default-sink {sink_name}")
break
elif mode == 'analog' and 'analog' in sink_name:
run_cmd(f"pactl set-default-sink {sink_name}")
break
return jsonify({'status': 'ok', 'mode': mode})
@app.route('/api/usb/scan', methods=['GET'])
def scan_usb():
out = run_cmd("pactl list sinks")
sinks = []
current = {}
for line in out.splitlines():
line_str = line.strip()
if line_str.startswith("Sink #"):
if current.get('name'):
sinks.append(current)
current = {}
elif line_str.startswith("Name:"):
current['name'] = line_str.split("Name:")[1].strip()
elif line_str.startswith("Description:"):
current['desc'] = line_str.split("Description:")[1].strip()
if current.get('name'):
sinks.append(current)
usb_sinks = [s for s in sinks if 'usb' in s.get('name','').lower() or 'usb' in s.get('desc','').lower() or 'dac' in s.get('desc','').lower()]
return jsonify(usb_sinks if usb_sinks else sinks)
@app.route('/api/usb/set', methods=['POST'])
def set_usb():
sink = request.get_json().get('sink')
if sink:
toggle_optical_hardware(False)
run_cmd(f"pactl set-default-sink {sink}")
return jsonify({'status': 'ok'})
@app.route('/api/spotify/player', methods=['GET'])
def get_player():
status = run_cmd("playerctl status 2>/dev/null")
if not status or "No players found" in status:
return jsonify({
'status': 'Stopped',
'title': '未在播放歌曲',
'artist': 'Spotify Connect',
'art_url': DEFAULT_COVER
})
title = run_cmd("playerctl metadata title 2>/dev/null") or "未知曲目"
artist = run_cmd("playerctl metadata artist 2>/dev/null") or "未知歌手"
art_url = run_cmd("playerctl metadata mpris:artUrl 2>/dev/null") or DEFAULT_COVER
return jsonify({
'status': status,
'title': title,
'artist': artist,
'art_url': art_url
})
@app.route('/api/spotify/control', methods=['POST'])
def player_control():
action = request.get_json().get('action')
if action == 'playpause':
run_cmd("playerctl play-pause 2>/dev/null")
elif action == 'next':
run_cmd("playerctl next 2>/dev/null")
elif action == 'previous':
run_cmd("playerctl previous 2>/dev/null")
return jsonify({'status': 'ok'})
@app.route('/api/bluetooth/scan', methods=['GET'])
def bt_scan():
run_cmd("bluetoothctl --timeout 4 scan on")
out = run_cmd("bluetoothctl devices")
devices = []
for line in out.splitlines():
parts = line.split(' ', 2)
if len(parts) >= 3:
devices.append({'mac': parts[1], 'name': parts[2]})
return jsonify(devices)
@app.route('/api/bluetooth/connect', methods=['POST'])
def bt_connect():
mac = request.get_json().get('mac')
run_cmd(f"bluetoothctl trust {mac}")
res = run_cmd(f"bluetoothctl connect {mac}")
return jsonify({'msg': f"连接结果: {res}"})
@app.route('/api/spotify/restart', methods=['POST'])
def restart_spotify():
run_cmd("pkill -f spotify")
run_cmd("flatpak run com.spotify.Client &")
return jsonify({'msg': 'Spotify Connect 已在后台重启!'})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
2. 踩坑:Systemd 服务点击无反应?必须用“用户级服务”!
一开始我把程序写成了 Systemd 系统服务(/etc/systemd/system/),结果网页能打开,但点击按钮没有任何反应。
【原理浅析】:PipeWire 声卡架构是绑定在当前登录用户的 Session Socket(XDG_RUNTIME_DIR)下的。系统级 Root 服务根本拿不到你的音频会话上下文,导致执行 wpctl 时静默失败。
解决方案:配置 Systemd 用户级服务(User Service)
# 1. 创建用户服务目录
mkdir -p ~/.config/systemd/user
nano ~/.config/systemd/user/music-web.service
写入以下配置(注意替换用户名路径):
[Unit]
Description=Q190 Web Music Console
After=pipewire.service sound.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/playstation/music-web/app.py
Restart=always
[Install]
WantedBy=default.target
启动服务并开启用户进程常驻(即使无 SSH/GUI 登录也能后台运行):
# 开启用户服务
systemctl --user daemon-reload
systemctl --user enable --now music-web.service
# 关键:允许 playstation 用户的后台进程常驻(替换为你自己的用户名)
loginctl enable-linger playstation
# 防火墙开放 8080 端口
sudo firewall-cmd --add-port=8080/tcp --permanent
sudo firewall-cmd --reload
终极成果展与总结
现在,这台老旧的 Lenovo Q190 已经静静地躺在我的音响柜角落里了。
只要按下电源键:
- 屏幕(如果不接就静默跑)显示极简的黑底 Lenovo 品牌 Logo 加加载圈。
- 自动静默进入桌面,无缝拉起 Spotify Connect。
- 背面光纤孔点亮红光,纯净的无损数字音频流源源不断地送入 DAC 解码器。
- 拿起手机打开浏览器访问
[http://192.168.0.241:8080](http://192.168.0.241:8080),滑动调音量、一键切线路,操作顺滑得像买来的成品 Hi-Fi 播放器。
整个系统平时内存占用 1.7GB,CPU 占用长期保持在 1% 以下,彻底打破了“2GB 内存老机器只能卖废品”的魔咒。折腾 Linux 的快乐,大概就在于这种把“电子垃圾”一步步调校成“心头好”的成就感吧!