Play live radio inside Obsidian — a player in your notes, a panel in the sidebar, and a mini player in the status bar.
Berry Radio turns the radio-browser.info directory into three real players that share one playback instance. Start a station from a note, switch it from the status bar, and never leave the note you are writing in.
| Where | What it is |
|---|---|
| In a note | A player card: cover, category, station name, and a single-row control (play/stop + volume) |
| Sidebar or tab | A full panel: search, category filter, station list, and a one-row player at the bottom |
| Status bar | A permanent mini player: play/stop, current station, and a menu to switch stations |
radio code block, the station name goes inside, and you get a player that stays in your prose.Player card in a note:

Sidebar panel with the one-row player at the bottom:

Status bar menu for switching stations:

Search for Berry Radio in Settings → Community plugins → Browse. Obsidian then keeps it up to date from the GitHub releases.
Download main.js, manifest.json and styles.css from the latest release and put them in:
<your vault>/.obsidian/plugins/berry-radio/
The folder name must match the plugin id berry-radio. Then enable it under Settings → Community plugins.
Add berry51/berry-radio to BRAT's beta list if you prefer installing from source.
This plugin needs the network, and it is the only thing it uses it for:
de1, nl1, fi1, at1). Responses are cached in memory for five minutes.favicon URL published in the directory, when that URL is HTTPS.The plugin collects no telemetry, has no accounts, requires no payment, and stores nothing outside your vault. Favorites and recently played stations live in the plugin's own data file (.obsidian/plugins/berry-radio/data.json). Plain HTTP streams are filtered out by default because Obsidian blocks mixed content; you can allow them in the settings.
npm install
npm run build # type-check + production bundle into main.js
npm test # unit and DOM tests
main.js is a deterministic production bundle produced by esbuild, with hls.js bundled in (Apache-2.0). The workflow in .github/workflows/build.yml rebuilds the bundle and fails if it differs from the committed one, so the release asset always matches the source.
在 Obsidian 里听电台。三个位置各有一个真正的播放器:
| 位置 | 形态 |
|---|---|
| 笔记里 | 内嵌播放器:顶部发丝线、左对齐融入正文,封面 + 分类标 + 台名 + 单行控制(播放/停止 + 音量) |
| 右侧栏 / 独立页面 | 完整面板:搜索 / 分类 / 列表 + 底部播放器(封面 · 台名 · 播放/停止 · 音量,同一行) |
| 状态栏 | 常驻迷你播放器:播放/停止 + 当前台名 + 切台箭头(下拉菜单切台,不离开笔记) |
点左侧功能区(ribbon)的电台图标 = 在主区域打开独立页面;命令面板另有「打开电台面板(右侧栏)」。
三处共享同一个播放实例与同一份状态,任一入口操作,其余两处立即同步。设置里可逐项开关,也可只留一种。
npm install
npm run build
mkdir -p "<你的库>/.obsidian/plugins/berry-radio"
cp main.js manifest.json styles.css "<你的库>/.obsidian/plugins/berry-radio/"
在 设置 → 第三方插件 启用。插件 id 为 berry-radio,文件夹名必须一致。
开发:npm run dev(esbuild watch)、npm test、npm run build。
```radio
北京交通广播
```
代码块正文是查询词,渲染成一张播放器卡片。留空则使用设置里的「默认电台」;两者都没有时卡片显示「选择电台」按钮。
卡片上的播放键只控制播放,不会改动你的笔记。
控制区只有一行:播放/停止 + 音量并排居中。电台是直播流,没有「上一曲 / 下一曲」——播放中按下即停止并断开连接(界面用暂停图标表达,行为是真停止:暂停会让缓冲继续跑,恢复时听到的是几分钟前的内容)。切台在列表里点选。
卡片样式(设置 → Berry Radio → 卡片排版):
视觉规则(完整见 DESIGN.md):
border-radius: 0,封面是正方形、播放键是正方形、分类标是直角矩形,没有圆形和药丸。#d86d43(暖赤陶):只出现在实心播放键、播放状态点、选中分类、焦点环和空态按钮;不跟随主题、不按电台派生色相。封面优先用电台自己的台标(favicon);没有时显示品牌淡橙底 + 橙字的方形色块(全场同一个色,不是每台一色)。
点左侧功能区(ribbon)的电台图标会在主区域打开独立页面;要开在右侧栏,命令面板执行 打开电台面板(右侧栏)。
「下一台 / 上一台」只在当前列表内切换,界面上没有按钮(电台是直播流,不需要曲目式方向键)。
设置里开启后常驻在右下角(不再只在播放时出现,停止后也不会消失):
菜单是插件自己画的浮层(直角、发丝线、跟随主题),没有用 Obsidian 的系统原生菜单——原生菜单是圆角的系统样式,和插件的全直角设计语言对不上。
| 命令 | 说明 |
|---|---|
| 打开电台面板(右侧栏) | 在右栏打开面板 |
| 打开电台页面 | 在主区域以标签页打开 |
| 插入电台到当前笔记 | 搜索并插入 radio 代码块 |
| 播放或停止 | 全局快捷键(直播流:播放中按下即停止并断开) |
| 停止播放 | 停止并清空 |
| 上一台 / 下一台 | 在当前列表内切换(只保留命令入口,界面上没有这两个按钮) |
| 收藏或取消收藏当前电台 | 切换收藏 |
同一个电台在公共目录里往往有多个源。不能按名称相似度挑——「中国之声」有 6 个候选,排第一的是可用 MP3,但恰好同名的第 4 个指向不可达的 HLS。
现在的规则:
电台目录来自 radio-browser.info 公开 API,无需密钥。中国区 hidebroken=true 约 2170 个频道,按点击热度排序。
用 Obsidian 的 requestUrl 拉取(绕过渲染进程跨域限制),对 de1 / nl1 / fi1 / at1 四个镜像轮询兜底,会话内缓存 5 分钟。收藏与最近收听保存在插件数据中,不联网。
插件不收集任何遥测、没有账号、不付费、不在 vault 之外写文件。音频流直接从广播方服务器送到本地,不录音、不转存、不分发。
app:// 属安全上下文,混合媒体会被拦。设置里可放开,失败属预期。app:// 源直接拒绝(如 ngcdn001.cnr.cn),该台不可用。MP3/AAC 直连流不受影响。isDesktopOnly 为 false,代码不含 Node/Electron 依赖,但无真机证据。设计锚:见 DESIGN.md。
单元与 DOM 测试:npm test → 5 个文件 48 个用例全通过。
真实宿主:Obsidian 1.12.4 / 1.14.4(macOS),独立 QA 库,CDP 驱动真机点击 + 截图:
| 验收项 | 结果 |
|---|---|
| 插件加载 | 0.9.0,卡片排版 compact(细线内嵌) |
| 笔记内嵌播放器 | 3 处:两张 ready(40px 方形封面 + 36px 方形播放键 + 分类标 + 音量),一张空态带实心「选择电台」 |
| 卡片播放 | 点播放键 → 「CNR-1 中国之声」playing,图标切为暂停,状态点转强调色 |
| 卡片停止 | 再点播放键 → status: idle、station: null(真停止,不是暂停) |
| 侧栏播放器 | 单行:封面 · 台名/状态 · 播放/停止 · 音量;与列表仅一条发丝线分隔 |
| 渲染进程异常 | 无 |
控制区断言(scripts/verify-transport.cjs,真机计算样式 + 几何测量):
| 断言 | 结果 |
|---|---|
| 控制区按钮数 | 笔记卡片 1 个、侧栏 1 个(skipButtons: 0)✓ |
| 播放键与音量同一行 | sameRow: true,垂直居中偏差 0px ✓ |
| 控制区是否单行 | transportH === playH(36/36、34/34)✓ |
| 卡片总高 | 144px(带独立音量行时为 172px)✓ |
| 播放键图标 | 播放中为 pause(按下即停止),不是 square ✓ |
侧栏播放器断言(scripts/verify-nowbar.cjs,300px 窄侧栏,空态与播放态各测一次):
| 断言 | 结果 |
|---|---|
| 行数 | 1 行(rows: 1)✓ |
| 滑块与轨道中心 | 偏差 0.00px ✓ |
| 滑块与行中心 | 偏差 0.00px ✓ |
| 播放键与行中心 | 偏差 0.00px ✓ |
| 边框 | 1px / 0px / 0px / 0px(只有顶部发丝线)✓ |
| 背景 | rgba(0, 0, 0, 0)(无背景色块)✓ |
| 整条高度 | 52.4px ✓ |
形状与配色断言(scripts/verify-shape.cjs,真机计算样式,遍历作用域内全部元素):
| 断言 | 结果 |
|---|---|
border-radius 违规数 |
0(扫描 617 个元素,全部 0px)✓ |
径向光晕 radial-gradient |
0 处 ✓ |
纯黑投影 rgb(0,0,0) |
0 处 ✓ |
| 强调色是否唯一 | 去重后仅 #d86d43 ✓ |
| 播放键 | 背景 rgb(216,109,67)、radius 0px ✓ |
| 选中分类 | accent-soft 13% 底 + accent 字、radius 0px ✓ |
踩坑记录 ①:Obsidian 主题会给
input[type=range]与输入框加 4px / 8px 圆角,.br-volume__range这种纯类名选择器特异性不够,形状锁会被击穿。必须写成.br-volume input.br-volume__range。踩坑记录 ②:原生滑块的
::-webkit-slider-thumb靠margin-top: -4px抵消 3px 轨道高度来居中,一旦主题改写轨道高度,滑块就整体偏上约 4px,且伪元素样式无法从外部读取/修正。0.7.0 起轨道、填充、滑块全部自绘,原生 input 透明叠层只负责交互,居中由top: 50% + translateY(-50%)保证。
控件外壳断言(scripts/verify-player-chrome.cjs,300px 侧栏 + ribbon):
| 断言 | 结果 |
|---|---|
| 播放中图标 | 侧栏 / 笔记卡片 / 状态栏均为 pause ✓ |
| 点 ribbon 图标 | 在主区域打开独立页面(inRoot: 1、inSidebar: 0)✓ |
| 状态栏显示时 | 视图底部留位 = 状态栏实际高度 25px ✓ |
| 状态栏隐藏时 | 视图底部留位 0px,播放器紧贴底边(gapBelowNow: 0)✓ |
| 状态栏隐藏后 | status-bar-item 宿主一并隐藏,不留空的点击区 ✓ |
踩坑记录 ③:Obsidian 的
.status-bar是position: fixed,并给.workspace-leaf-content .view-content设了padding-bottom: max(safe-area-inset-bottom, 32px)给它留位。 想抹掉这段空白,选择器必须带上.workspace-leaf-content .view-content(0,2,0)才能压过.br-view(0,1,0), 并且不能写死 0——状态栏显示时会把播放器盖住。正确做法是按状态栏实测高度动态写--br-status-inset。
状态栏断言(scripts/verify-statusbar.cjs,真实宿主 + 真实点击):
| 断言 | 结果 |
|---|---|
| 空闲时常驻 | 可见、宿主可见、文案「选择电台」、2 个按钮(播放 + 切台)✓ |
| 播放中图标 | lucide-pause ✓ |
| 停止后 | 条目与宿主都还在,台名保留,status: idle / station: null ✓ |
| 停止后再按播放键 | 重新播放刚才那台(status: playing)✓ |
| 点台名 | 弹出切台菜单,含「最近收听」「收藏」两个分组 + 「搜索电台…」「打开电台面板」✓ |
| 菜单里能直接切台 | 列出 10 个最近收听 + 收藏电台 ✓ |
| 当前笔记不被切走 | 打开菜单前后 activeLeaf 都是 验收测试.md ✓ |
未覆盖:移动端真机、社区目录审核、公开发布。
MIT。hls.js 以 Apache-2.0 打包进 main.js。