Steam Deck 音乐插件:播放队列(Queue)行为设计规范
本规范定义 decky-music 在不同播放入口下的队列语义。队列真相源由 Python bridge 维护,前端 UI 只触发动作和展示状态。
1. 核心队列模式
队列分为两种互斥模式:普通列表模式(Normal Queue) 与 电台流模式(Radio Stream)。
1.1 普通列表模式
- 特征:有限长度静态列表,如歌单、专辑、搜索结果。
- 控制:用户可查看完整队列,可手动上一首 / 下一首。
- 循环策略:支持单曲循环、列表循环、随机播放。
- 持久化:随
settings.json落盘,重启 Steam Deck 后可恢复。
普通队列持久化结构为 "queue": {"items": [{id, media_mid, name, singer, cover, duration}], "index": 3}。存 id 类字段(歌曲 id + QQ 解析 URL 所需的 media_mid)+ 展示字段(恢复后队列浮层 / 状态徽章直接显示真实信息),键白名单写入,绝不存解析出的播放 URL(限时 vkey);播放时按 id 重新向 provider 解析。写入必须走临时文件 + os.replace() 原子替换。
1.2 电台流模式
- 特征:动态、无限或分批拉取的歌曲流,如 NCM 私人 FM、QQ 猜你喜欢、QQ 雷达推荐。
- 无上一首:电台只能往前走,不支持回退。
- 无播放模式切换:单曲循环或随机在电台模式下无意义。
- 动态补水:当当前批次播放到倒数第二首时,bridge 自动向 provider 请求下一批并追加到内存队列。
- UI 表现:电台沉浸页隐藏上一首和播放模式;队列浮层不展示未来曲目,只显示“正在收听电台”与退出入口。
2. 播放触发场景与队列策略
| 触发场景 | 用户操作 | 队列行为策略 | 模式 |
|---|---|---|---|
| 歌单 / 专辑 / 排行榜 | 播放全部 | 全量替换:清空当前队列,写入完整列表,从第 1 首播放。 | 普通列表 |
| 歌单 / 专辑 / 排行榜 | 点击列表中某首单曲 | 上下文替换:写入该单曲所在列表,索引定位到该单曲。 | 普通列表 |
| 搜索结果页 | 点击某首单曲 | 全量替换:写入当前页搜索结果,从选中曲目播放。 | 普通列表 |
| 任意列表单曲 | X -> 下一首播放 |
插队追加:插入到当前播放索引之后。 | 普通列表 |
| 任意列表单曲 | X -> 添加到队列末尾 |
尾部追加:追加到队列最后。 | 普通列表 |
| 智能电台 / 私人 FM | 点击电台入口 | 模式覆写:清空普通队列,切换电台流,拉第一批并开播。 | 电台流 |
2.1 模式切换互斥规则
- 普通列表 -> 电台流:原普通列表被抛弃,进入电台流。
- 电台流 -> 普通列表:电台流被丢弃,重新建立静态列表。
- 跨 provider 切换:QQ 与 NCM Song ID 体系不通用,切 provider 时队列强制清空并停止播放。
- 登录态不随切换清除:
settings.json中accounts按 provider 分 key 存储。切换只决定 spawn provider 时注入哪份 cookie。
3. Bridge 队列控制
bridge 必须处理 player 的 ended 事件以决定下一首。协议 v1 事件形态为 {"ev":"player","type":"ended","data":{}},即 protocol.ChildEvent(ev="player", type="ended")。
自动切歌逻辑:
- 单曲循环:
seek(0)重新播放当前索引。 - 普通列表:索引
+1。到末尾时:- 列表循环:索引重置为
0。 - 随机播放:从随机洗牌后的内部索引取下一首。
- 列表循环:索引重置为
- 电台流:索引
+1。若当前已是内存批次倒数第二首,异步调用 provider 对应接口补水。
3.1 队列为空
- 触发:初次安装、跨 provider 切换、用户清空队列。
- 表现:Tab 状态徽章显示空态;正在播放页显示“暂无播放”;进度和切歌控件禁用。
- 拦截:此时
Start盲操无效,Footer Legend 不展示“播放 / 暂停”为可执行动作。 - 取消语义(#57):清空、空
play_queue、空电台及移除最后一曲,在首个 await 前作废旧播放代次并清除已加载/恢复状态。 旧song_url、按需启动或写锁等待结束后都必须复核代次;过期load/seek/meta/stop不得继续写入。 已写出的旧load由后续 stop/new load 的 player 代次失效,不能在旧响应返回时无条件 stop 新歌。 player 用同一代次锁保护后台 load 的检查及音频入队,与 stop/new load 的代次更新互斥;不跨 HTTP await 持锁。 - 迟到事件:空队列不能被旧 playing/paused/unloaded/ended 或流死亡事件复活;有效非空队列的流错误仍须进入恢复路径。
3.2 读停摆不是正常结束
流尚未读完、缓冲为空且持续等不到数据时,player 在原有 30 秒读等待上限内判定停摆。
锁内重新检查最终补货、已有错误和 EOF 后才记录失败,并失效旧 producer 的结果;正常 EOF 仍先排空缓冲。
音频层通过 failure probe 发 fetch_failed,不发 ended,因此不会自动跳到下一首。
bridge 保留当前曲与 _resume_at,resume() 重载后尝试 seek 回断点,seek 失败才按既有规则从头播放。
4. UI 队列浮层
普通大屏页面按 Y 打开队列浮层。QQ 智能电台、NCM 私人 FM 等专属沉浸页可按各自 UI 规格覆盖 Y 键,队列入口改由页面按钮或不展示未来队列。
浮层是右侧临时面板,不是常驻侧栏,也不是居中确认框。保留 Decky showModal/ModalRoot
的模态焦点与返回机制;近黑背景、小圆角、轻遮罩,顶部/底部避开系统状态区和 Footer Legend。
宽度随视口约束,窄屏可占满可用内容宽度,不按 Steam Deck 的物理分辨率写死尺寸。
普通模式下:
- 所有歌曲均可导航;按可见区域窗口化渲染,不再固定截取“当前曲前后各 50 首”。
- 打开时定位并聚焦当前曲;后续曲目变化不抢走用户正在浏览的焦点。
- 当前曲用 Steam 蓝细标记与播放/暂停图标;手柄焦点独立使用白色细描边和灰色渐变。
A播放聚焦歌曲且保持浮层打开,X直接移除该曲;标题区提供低强调的“清空”操作。- 移除后保留该位置的焦点,末行移除回到前一行;清空后显示空态并聚焦返回入口。
电台模式下:
- 不展示未来曲目。
- 当前单曲卡片仅展示信息,不可聚焦,也不显示无效的
A 选择 / X 移除提示。 - 标题为“正在收听电台”,辅以“后续歌曲由电台自动推荐”说明。
- “退出电台”放在次级操作区,执行后清空并关闭浮层;默认焦点在安全的返回按钮上。
- bridge 对电台模式下的队列编辑 callable(播放队列索引 / 插队 / 追加 / 移除)一律 no-op,避免 UI 快照索引映射到被隐藏的历史 / 未来曲目;只有“清空队列”会退出电台。
读取失败或快照畸形时显示局部错误和重试入口,不把未知字段直接渲染成 React 节点。
Footer Legend 始终由系统渲染;B 关闭后恢复原焦点,原节点消失时由原生导航回退到可用入口。
5. 持久化总览
| 状态 | 是否持久化 | 说明 |
|---|---|---|
| 普通队列(ids + index) | 是 | 随 settings.json 落盘;只存 id,URL 重新解析。 |
| 电台流内容 | 否 | 动态推荐流存了无意义;只记 queue_mode: "radio" 与电台类型。 |
| 播放模式 / 音量 | 是 | bridge 统一持有。 |
| 登录态(双 provider cookie) | 是 | 按 provider 分 key,切换 provider 不清除,文件 chmod 0600。 |
| 跨 provider 切换后的队列 | 清空 | Song ID 体系互不通用,无保留意义。 |