Nuclear 远程遥控器 Nuclear Jam 实战指南:手机扫码即连,SSE 实时同步的实现原理
【免费下载链接】nuclearStreaming music player that finds free music for you项目地址: https://gitcode.com/GitHub_Trending/nu/nuclear
Nuclear Jam 是 Nuclear 内置的局域网远程控制系统:开启后,任何一台联网设备只要有一个浏览器,就能成为 Nuclear 的遥控器——播放/暂停、切歌、调整队列、搜索音乐都可以远程完成。本篇以官方用户手册的远程遥控器文档为主体,完整覆盖开启方式、二维码连接流程与遥控器界面各区块的功能,并结合仓库中遥控器前端与后端 HTTP 服务的源码,讲清"手机操作如何立刻同步到桌面端"背后的 Server-Sent Events(SSE)同步机制、断线重连策略与端口绑定细节,读完你可以熟练启用该功能,也能理解其端到端的实现链路。
工作原理:Nuclear 内置一个局域网 Web 服务器
Nuclear Jam 的本质是 Nuclear 启动的一个小型 Web 服务器,直接监听在你的局域网地址上。你的手机或其他设备通过局域网直连这台电脑,所有数据不离开你的掌控范围(no data ever leaves your grasp),不经过任何第三方云端。
从源码结构看,这个后端服务位于 Tauri 侧的 Rust 代码中,端口策略与文档描述一致:
- http_api/mod.rs 中定义了
REMOTE_PORT_START: u16 = 4120,即文档所说的4120–4129 端口段; - 绑定调用为
bind_first_available_port("0.0.0.0", REMOTE_PORT_START, REMOTE_PORT_END),即从 4120 开始依次尝试,占用则顺延到下一个可用端口。
这意味着:如果服务器的 LAN 地址是192.168.1.42且 4120 端口空闲,Remote URL 就是http://192.168.1.42:4120;若端口被占用,URL 会变成该范围内的下一个可用端口。服务器只监听本地网络——其他设备必须与电脑处于同一个 Wi-Fi 或有線 LAN 才能连上,无法从公网访问,这是文档明确强调的安全边界。
该服务器同时承担两个角色:
- Remote URL:在浏览器中打开即为遥控器页面(Nuclear Jam 的远端 UI);
- API URL:面向脚本与第三方集成的 HTTP API,详见 HTTP API 文档。
开启 Nuclear Jam:三步操作与两个只读字段
开启流程非常直接:
- 打开 Nuclear,进入Settings → Integrations;
- 将Nuclear Jam开关打开;
- 开关下方会出现两个只读字段:
- Remote URL:在浏览器中打开它即可使用遥控器;
- API URL:供脚本和其他集成使用(面向开发者),例如
http://192.168.1.42:4120/api。
这两个字段在源码中对应核心设置(core settings)里的两个键,可从 JamQrCodeButton.tsx 看到遥控器功能读取的正是它们:
const [jamEnabled] = useCoreSetting<boolean>('integrations.jam.enabled'); const [remoteUrl] = useCoreSetting<string>('integrations.jam.remoteUrl');其中integrations.jam.enabled就是设置面板里那个开关背后持久化的布尔值,integrations.jam.remoteUrl则由服务器成功绑定端口后回写生成的局域网 URL。开关关闭时,整个 Jam 功能(包括顶栏的二维码按钮)都不渲染。
从手机连接:顶栏二维码按钮与二维码弹窗
开启 Jam 后,Nuclear 顶栏会出现一个小小的二维码图标(位于主题切换器旁边)。点击它,弹出一个 Popover,内含:
- 一张 200×200 的二维码 SVG(中间嵌有 Nuclear 的 logo 图标);
- 二维码下方显示 Remote URL 文本。
用手机的相机扫码,或者干脆在局域网内任意设备的浏览器中直接输入 Remote URL,即可加载 Nuclear Jam 遥控器。
二维码按钮的实现见 JamQrCodeButton.tsx,几个值得注意的实现细节:
if (!jamEnabled) { return null; // Jam 未开启时按钮不渲染 } return ( <Tooltip content={t('qrCode.tooltip')} side="bottom"> <Popover trigger={<QrCode size={20} />} anchor="bottom" ...> <QRCodeSVG className="text-primary rounded-lg" value={remoteUrl ?? ''} // 二维码内容就是 Remote URL size={200} imageSettings={{ src: LOGO_URL, height: LOGO_SIZE, width: LOGO_SIZE, excavate: true }} /> <InfoField label={t('qrCode.instructions')} value={remoteUrl} /> </Popover> </Tooltip> );即:二维码编码的内容就是 Remote URL 本身,integrations.jam.enabled为假时组件直接返回null,与文档"开关打开后二维码图标才出现"的行为完全对应。
遥控器界面:单屏四区块与连接状态徽章
遥控器 UI 是一个单屏界面,自上而下分为四个部分(对应文档中的截图布局):
- 搜索栏(头部):输入关键词远程搜索音乐;
- Now Playing(正在播放):封面、曲名、艺人;
- Controls(控制区):上一首、播放/暂停、下一首,外加进度拖动条(seek bar)、随机播放(shuffle)、重复(repeat)、发现(discovery)开关;
- Queue(队列):当前播放曲目高亮,每条右侧有一个 X 按钮用于移除。
头部还有一个连接状态徽章,文档中列出的四态在代码里对应ConnectionStatus的四个取值。从 RemoteControl.tsx 可以看到状态到界面元素的完整映射:
if (state.connectionStatus === 'failed') { return <NuclearJam><NuclearJam.Error ... /></NuclearJam>; // 彻底失败页 } if (!state.synced || state.connectionStatus === 'connecting') { return <NuclearJam><NuclearJam.Connecting ... /></NuclearJam>; // 连接中转圈页 } // 正常界面:Header 上的 connectionStatus 驱动徽章文案 <NuclearJam.Header connectionStatus={state.connectionStatus} connectionStatusLabels={{ connecting: t('connection.connecting'), connected: t('connection.connected'), reconnecting: t('connection.reconnecting'), failed: t('connection.failed'), }} >远程操作即时生效,且多设备同步:在遥控器上跳过一首歌,桌面端立刻变化;反之亦然。多台设备可以同时连接并保持同步——这是由 SSE 事件推送保证的(原理见下一节)。
状态同步机制:SSE 事件流与重连策略
远端页面与 Nuclear 之间的实时同步基于 HTTP API 的 SSE 端点GET /api/events。服务器在状态变化时推送三种命名事件,事件携带该域的完整状态(而非增量 diff):
event: queue data: {"items":[...],"currentIndex":3} event: playback data: {"status":"playing","seek":42.1,"duration":213.0} event: settings data: {"shuffle":false,"repeat":"off","discovery":false,"language":"en_US","dark":false,"themeId":"default"}远端页面通过 useSSESync.ts 把这三类事件分别写入 Zustand 全局 store:
useEventSourceListener(source, ['queue'], (event) => { useRemoteStore.getState().setQueue(JSON.parse(event.data)); }); useEventSourceListener(source, ['playback'], (event) => { useRemoteStore.getState().setPlayback(JSON.parse(event.data)); }); useEventSourceListener(source, ['settings'], (event) => { useRemoteStore.getState().setSettings(JSON.parse(event.data)); });store 的完整结构定义在 remoteStore.ts,除 queue / playback / settings 外还保存connectionStatus与synced标志;settings 的默认值为shuffle: false, repeat: 'off', discovery: false, language: 'en_US', dark: false, themeId: DEFAULT_THEME_ID,与 SSE 事件示例中的字段一一对应。
断线处理逻辑集中在 useEventSource.ts,关键参数:
| 常量 | 值 | 含义 |
|---|---|---|
RECONNECT_DELAY_MS | 3000 ms | 每次重连前的等待时间 |
MAX_RETRIES | 3 | 连续失败 3 次后置为 failed,不再重试 |
状态机行为:open事件把状态置为connected并清零重试计数;error且连接已关闭时递增重试计数——超过 3 次即显示"已断开"错误页(对应文档中 Disconnected 语义),否则显示"重连中"(Reconnecting)并在 3 秒后自动重拨。这也解释了为什么多设备"全部保持同步":只要 SSE 恢复,下一次事件携带的就是全量最新状态,不会丢失中间变更。
控制指令通道:REST 请求 + "让 SSE 纠正状态"
与 SSE 的"服务器→远端"下行通道配合的,是远端→服务器的上行通道:一组 REST 动作请求。所有遥控器按钮的动作都集中在 useRemoteActions.ts:
| 遥控器操作 | 请求 | 说明 |
|---|---|---|
| 播放/暂停 | POST /api/playback/toggle | 无 body |
| 下一首 / 上一首 | POST /api/playback/next/previous | 无 body |
| 拖动进度 | POST /api/playback/seek | { "seconds": (percent/100) * duration },由当前播放时长换算 |
| 随机开关 | POST /api/playback/shuffle | { "enabled": !当前shuffle } |
| 重复模式 | POST /api/playback/repeat | 在off → all → one → off间循环 |
| 搜索 | POST /api/search | { "query", "types": ["tracks"], "limit": 10 } |
| 加入队列 | POST /api/queue/add | { "tracks": [track] } |
| 移除队列项 | POST /api/queue/remove | { "ids": [itemId] } |
其中 seek 的实现体现了"用同步下来的状态做本地计算"的写法:onSeek从 store 读取playback.duration,把拖动条的百分比换算成秒数再发请求;onRepeatToggle用一个nextRepeatMode = { off: 'all', all: 'one', one: 'off' }映射实现三态循环,与文档中"repeat 按钮"的行为一致。
值得一提的是这个防御性设计:
const postAction = async (path: string, body?: unknown) => { try { await post(path, body); } catch { // SSE will push corrected state } };动作请求若失败会被静默吞掉,注释写得很直白:"SSE 会推送纠正后的状态"。这正是"事件带全量状态"这一设计的收益——上行失败不会让远端 UI 与桌面端永久失步,下一次queue/playback事件到达就会刷新。
搜索音乐:抽屉式结果与空队列自动播放
在遥控器顶部的搜索栏输入关键词后,一个抽屉(drawer)会从上方滑下,展示匹配到的曲目;点击(或轻触)某条即可把它加入队列,抽屉随即关闭。文档中"如果队列为空,播放会自动开始"这一点,在源码里有清晰对应:
onAddToQueue: async (track: Track) => { const wasEmpty = (getState().queue?.items.length ?? 0) === 0; await postAction('/api/queue/add', { tracks: [track] }); if (wasEmpty) { await postAction('/api/playback/play'); // 空队列时自动起播 } },先记录加入前队列是否为空,加入成功后若是空队列就补发一个POST /api/playback/play,自动开始播放。
从队列移除曲目
队列中每条曲目右侧的X 按钮对应onRemoveFromQueue(itemId),即POST /api/queue/remove,body 为{ "ids": [itemId] }。移除动作由 SSE 的queue事件广播给桌面端和其他所有已连接的遥控器,实现文档所说的"多设备同时在线且全部保持同步"。
小结
Nuclear Jam 的设计可以概括为一条"双通道"链路:上行走 REST(/api/playback/*、/api/queue/*、/api/search等动作端点),下行走 SSE(/api/events推送 queue/playback/settings 三类全量状态事件),配合 3 秒间隔、3 次上限的自动重连,以及"全量事件自动纠正远端状态"的兜底策略,构成了一个零云端、局域网内即时同步的远程控制系统。服务端绑定0.0.0.0上 4120–4129 范围内的首个可用端口,客户端则通过integrations.jam.enabled/integrations.jam.remoteUrl两个核心设置驱动顶栏二维码按钮的显隐与内容。想进一步扩展脚本化控制,可直接参考 HTTP API 文档 中的完整端点列表与错误码约定。
【免费下载链接】nuclearStreaming music player that finds free music for you项目地址: https://gitcode.com/GitHub_Trending/nu/nuclear
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考