接 入 方 案 · 2 0 2 6 - 1 1 - 0 5

把云听的电台
当作歌单

radio.cn(云听)的网页版接口不需要登录就能拿到全国 893 个广播电台:直播是 HLS 流,央广 19 个台还能取到当天完整的节目单回听,节目是可直接拖动的 m4a。 这意味着电台能直接套进现有歌单模型——一个电台一个歌单,直播是置顶曲目,回听节目是歌单里的歌,播放、队列、收藏、小组件都不用另造一套。

接口:ytmsout.radio.cn 无需登录 已实测 893 个电台 直播 HLS · 回听 m4a
00

结论

接口是云听网页版自己用的那套签名接口,参数和签名方式都写在页面 JS 里,没有用户态、没有鉴权头、没有设备指纹。能做,而且不用改动现有歌单页的交互。

893个电台19 个央广 + 31 个省级地区
19个台可回听央广系列,当天节目单 m4a
10个类型筛选新闻 / 财经 / 交通 / 音乐…
0处需要登录列表、直播、回听都不要 cookie
一句话形态:云听作为第 13 个 provider 接进来,歌单页顶部多一个「云听」平台;推荐页是热门电台,分类页是「类型 / 地区」两行筛选;点开一个电台就是歌单页,第一条是「直播 · 正在播出」,下面是今天的节目单。
01

图标

官方图标是红底圆角方块里的「云听」连笔字标。按现有平台图标的规格把它描成单色矢量:24dp 画板、实心剪影、一个 path,放进 36dp 圆角色块(图标占 70%),颜色跟 onSurfaceVariant / primaryContainer 走。字形照官方原样提取,只做单色化,不重新设计。

官方字标 · 单色化

采用
96
48
36(列表实际)
选中
24
20

从官方字形提取外轮廓,笔画统一到 约 1.3dp(和 QQ、酷狗那批图标同重),整块 20 × 15dp 居中放在 24dp 画板里。36dp 列表位清楚可辨,20dp 小头像上只剩骨架——和官方图标本身在小尺寸下的表现一致。

此前自造的「云 + 声波」

未采用
48
24

这套是照「云 + 声波」自己画的标记,和官方字标不是一回事,已废弃——平台图标应该照官方标志单色化,不该另造符号。

和现有图标并排

QQ 音乐
酷狗
本地
云听(新增)

规格与代码

项值
画板24 × 24 dp / viewport 24
图形边界x 2.0–22.0,y 4.4–19.6(约 20 × 15.2dp,居中)
笔画约 1.3dp,等宽;和官方字标同形,只做单色化和轻微加粗
填充单 path + fillType="evenOdd",两个子路径(外轮廓 + 一个封闭空腔)
色块36dp 圆角 10dp(size * 0.28),图标 25dp
颜色常态 surfaceContainerHighest / onSurfaceVariant;选中 primaryContainer / onPrimaryContainer + 1.5dp primary 描边
<!-- res/drawable/ic_provider_yunting.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp" android:height="24dp"
    android:viewportWidth="24" android:viewportHeight="24">
    <path android:fillColor="#FFFFFFFF" android:fillType="evenOdd"
        android:pathData="M21.25,19.49 C20.89,19.39 20.78,19.28 20.6,18.84 C20.56,18.73 20.56,18.73 20.56,14.67 C20.56,10.09 20.58,10.5 20.38,10.32 C20.2,10.16 20.2,10.16 19.47,10.16 C18.74,10.16 18.58,10.17 18.45,10.27 C18.27,10.4 18.28,10.18 18.27,13.03 C18.26,15.87 18.27,15.68 18.1,15.94 C18.05,16.02 17.95,16.17 17.88,16.3 C17.71,16.57 17.68,16.59 17.21,16.79 C16.61,17.04 16.31,17.06 15.78,16.85 C15.13,16.58 15,16.46 14.75,15.87 C14.57,15.42 14.58,15.81 14.58,11.31 L14.58,7.3 L14.51,7.15 C14.35,6.81 14.26,6.77 14.03,6.95 C13.76,7.17 13.79,6.59 13.79,12.14 C13.79,17.99 13.82,17.5 13.45,17.9 C12.92,18.46 12.82,18.5 12.12,18.52 C11.34,18.54 11.15,18.5 10.87,18.23 C10.77,18.14 10.63,18.02 10.56,17.95 C10.37,17.79 10.29,17.59 10.23,17.13 C10.22,17.03 10.21,16.1 10.21,14.86 C10.2,12.65 10.2,12.67 10.1,12.44 C9.94,12.12 9.71,12.06 9.57,12.3 C9.43,12.57 9.43,12.53 9.42,14.19 L9.41,15.69 L9.32,15.93 C9.12,16.54 9.09,16.57 8.53,16.81 C8.12,16.98 8.26,16.97 6.39,16.97 C4.43,16.97 4.68,17 4.12,16.73 C3.92,16.64 3.68,16.38 3.54,16.13 C3.4,15.86 3.48,15 3.68,14.57 C3.82,14.29 3.85,14.18 3.9,13.9 C3.95,13.57 4.03,13.32 4.14,13.09 C4.27,12.83 4.3,12.74 4.37,12.36 C4.44,11.97 4.46,11.92 4.6,11.62 C4.74,11.33 4.72,11.38 4.83,10.88 C4.9,10.53 4.93,10.43 5.02,10.22 C5.28,9.64 5.31,9.54 5.24,9.42 C5.14,9.27 5.24,9.28 3.67,9.28 C1.9,9.28 1.99,9.3 2.01,8.77 C2.02,8.28 2.22,7.99 2.63,7.86 C2.78,7.82 11.66,7.81 11.73,7.85 C11.83,7.92 11.86,8.02 11.86,8.3 C11.86,8.64 11.84,8.69 11.56,8.97 C11.24,9.31 11.49,9.28 9.34,9.27 C6.82,9.26 6.99,9.23 6.7,9.78 C6.53,10.12 6.51,10.17 6.45,10.51 C6.37,10.93 6.35,11.01 6.2,11.33 C6.08,11.57 6.06,11.63 5.99,12.01 C5.89,12.51 5.9,12.45 5.74,12.8 C5.62,13.07 5.6,13.13 5.53,13.49 C5.45,13.89 5.39,14.06 5.27,14.29 C5.19,14.45 5.14,14.59 5.1,14.85 C5.08,14.95 5.04,15.1 5.01,15.19 C4.94,15.37 4.95,15.41 5.09,15.49 C5.22,15.56 7.57,15.57 7.75,15.5 C8.03,15.39 8.03,15.42 8.03,13.64 C8.04,11.81 8.04,11.82 8.28,11.55 C8.34,11.48 8.44,11.35 8.5,11.27 C8.62,11.08 8.67,11.04 8.82,10.97 C9.61,10.63 10.68,10.74 11.03,11.2 C11.1,11.29 11.19,11.4 11.24,11.44 C11.51,11.65 11.5,11.55 11.52,14.33 C11.53,17.02 11.51,16.77 11.77,17 C12.01,17.21 11.99,17.21 12.19,17 C12.29,16.89 12.39,16.77 12.42,16.73 C12.46,16.64 12.49,8.81 12.45,8.59 C12.42,8.43 12.48,6.84 12.52,6.64 C12.62,6.2 12.67,6.1 12.9,5.93 C12.97,5.88 13.08,5.78 13.15,5.71 C13.35,5.5 13.5,5.46 14.07,5.44 C14.67,5.42 14.99,5.47 15.18,5.64 C15.22,5.68 15.33,5.77 15.41,5.84 C15.5,5.91 15.6,6.01 15.64,6.09 C15.69,6.16 15.74,6.24 15.77,6.28 C15.9,6.47 15.96,6.55 15.98,6.63 C16,6.69 16.01,7.61 16.02,10.95 C16.03,15.19 16.03,15.19 16.08,15.29 C16.21,15.58 16.59,15.6 16.72,15.34 C16.81,15.16 16.81,15.2 16.81,10.46 C16.81,5.11 16.78,5.6 17.14,5.04 C17.51,4.48 17.39,4.51 19.85,4.49 C22.21,4.47 22,4.42 22,4.96 C22,5.34 21.91,5.5 21.6,5.66 C21.37,5.78 21.39,5.78 20.06,5.77 C18.67,5.77 18.65,5.77 18.5,5.9 C18.29,6.09 18.28,6.12 18.27,7.25 C18.26,8.82 18.14,8.69 19.71,8.71 C20.98,8.72 20.88,8.71 21.25,8.95 C21.59,9.18 21.88,9.54 21.94,9.8 C21.99,10.04 21.98,19.27 21.92,19.36 C21.82,19.54 21.6,19.58 21.25,19.49 Z M3.22,6.19 C3.06,6.1 2.99,5.69 3.1,5.47 C3.17,5.32 3.53,4.99 3.65,4.94 C3.81,4.89 10.71,4.86 10.83,4.91 C11,4.98 11.07,5.49 10.95,5.72 C10.83,5.94 10.51,6.17 10.28,6.2 C9.98,6.24 3.31,6.23 3.22,6.19 Z"/>
</vector>

接入只有两行:ProviderIcon.kt 的 providerIcon() 加 "yunting" → R.drawable.ic_provider_yunting,providerIconByName() 加 name.contains("云听")。

02

接口:已实测的部分

四个 GET 接口撑起全部功能,域名 ytmsout.radio.cn。签名放在 header 里:把 query 参数按 key 排序拼成 k=v&k=v,再接 &timestamp=<毫秒>&key=<网页 JS 里的常量>,取大写 MD5。

用途路径参数返回
类型/web/appCategory/list/all任意占位参数(无参会被网关拦)10 个类型:全部 / 热门 / 新闻 / 财经 / 生活 / 交通 / 综艺 / 音乐 / 故事 / 民族
地区/web/appProvince/list/all同上32 项:国家 + 31 个省级地区(含新疆兵团)
电台列表/web/appBroadcast/listcategoryId、provinceCode,两个维度可组合电台数组,单次最多 74 条(山东全省)
可回听电台/web/appBroadcast/listenbackListprovinceCode=''(空串才是全国)19 个央广台;省级传编码返回空
节目单/web/appProgram/listByDatedate=YYYY-MM-DD、broadcastId当天节目:名称、起止时间、回听地址、封面
// 签名示例(Kotlin 侧照抄即可,key 就是网页 js/api.js 里的常量)
val sorted = params.toSortedMap().entries.joinToString("&") { "${it.key}=${it.value}" }
val sign = md5("$sorted&timestamp=$ts&key=$KEY").uppercase()

headers += mapOf(
  "Content-Type" to "application/json",
  "equipmentId"  to "0000",
  "platformCode" to "WEB",
  "timestamp"    to ts.toString(),
  "sign"         to sign,
)

直播:带签名的 HLS

每个电台返回四条流地址,playUrlMulti / mp3PlayUrlHigh 是同一路 192k 左右立体声(10 秒 ≈ 258 KB),playUrlLow 是低码率。地址里带 key 与 time:time 是签发时刻的十六进制秒值,每次请求列表都会重新签一份,所以播放前必须重取,不能把地址长期存进队列。

http://ytcast2.radio.cn/110/radios/40639/index_40639.m3u8?type=1&key=8f58…&time=6aec66aa

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXTINF:10.008778,
segment20261105030803-00005840.ts   ← 相对路径,Media3 自己拼

回听:30 分钟到 2.5 小时的整段 m4a

节目地址没有 token,可以直接拖动 seek,单集 20–180 MB(30 分钟约 43 MB)。以今天(11-05)的中国之声为例,节目单 23 条:06:30 新闻和报纸摘要、07:00 新闻纵横(2 小时)、09:30 新闻进行时(2.5 小时)、19:00 新闻有观点……

https://ytrecordbroadcast.radio.cn/echo/2/2963630829427583833.m4a?e=0&ps=1&r=1
Content-Type: audio/x-m4a   Content-Length: 43642466   → 30 分钟
没有单台详情接口。猜过的 /web/appBroadcast/detail、/get、/info 全部 404,所以「播放前换新 token」只能回到列表接口按 categoryId + provinceCode 拉一次再挑出 contentId——列表最多 74 条,代价可以接受。
03

形态:电台即歌单

不新增页面类型。云听复用 PlaylistsScreen 的平台 chip、分类筛选、歌单卡片和 CollectionScreen,只把数据换掉。

歌单页 · 云听平台 chip 多一个「云听」,推荐 = 热门电台
类型 / 地区筛选两组 chips,选一个维度后按电台过滤
电台卡片 → 歌单页封面是台标,标题是台名,副标题是「正在直播:……」
直播 + 今日节目单第一条置顶「直播」,其余是今天的回听节目

数据映射

云听myMusic 模型字段
电台Playlistid=contentId,name=title,cover=image,description=subtitle(无节目单时留空),source="yunting",link="https://www.radio.cn/pc-portal/erji/radioStation.html?id=639"
直播Song(歌单第一条)name="直播 · 中国之声",url=playUrlMulti,ext="m3u8",duration=0,extra["live"]="1",extra["province"] 留着换 token 用
回听节目Songid=节目 id,name=programName,artist=台名,album=节目日期,duration=(endTime-startTime)/1000,url=playUrlHigh,ext="m4a",cover=image,extra["replay"]="1"
类型 / 地区PlaylistCategorygroup="类型" | "地区",id="c{categoryId}" / "p{provinceCode}"

Provider 实现(data/native/YuntingProvider.kt)

方法行为
categories()类型 + 地区各拉一次 list/all,合成 PlaylistCategory 列表(地区排在类型后面)
recommendedPlaylists()appBroadcast/list {0,0} 取前 18 个央广台
categoryPlaylists(id)解析 c… / p…,另一个维度填 0,返回电台卡
playlistSongs(id)置顶直播 + 若在 19 个央广台里则拉 listByDate 拼今天节目;地区台只有直播一条
playableStream()直播项:按 extra["province"] 重拉列表换新 playUrlMulti;回听项直接返回原地址
parsePlaylist(link)认 radioStation.html?id=…,回到列表里找到该台
其余搜索 / 歌词 / 下载 / 登录 / 专辑 / 歌手 / 榜单全部不开,ProviderInfo 对应开关保持 false
为什么不做「每个节目一个歌单」:回听节目是整段广播,不是单曲,硬拆会得到几百个只有一条的"歌单"。以电台为歌单、节目为曲目,正好对应收听动作:先选台,再选今天听哪一段。
04

界面稿

视觉直接照 expressive-redesign.html 里那套手机原型:同一组 M3E 令牌(#006b5f 种子的 --md-*)、同一批组件——m-big 大标题、m-chips 平台 chip、m-bgroup 连接按钮组、m-feature 大卡、m-grid2/m-card 网格、m-colhead/m-colbg 详情头、m-split 播放全部、m-seglist/m-row 分段行、m-mini 胶囊、m-nav 底栏。云听只改内容。

9:41
歌单
歌单

云听 · 为你推荐

网易云音乐QQ 音乐酷狗云听
正在直播
中国之声 · 19:00–20:00 · 云听新闻有观点
热门电台
38.6 万经济之声正在直播:天天 3.15
音乐之声正在直播:中国 TOP 排行榜
中国交通广播正在直播
北京交通广播正在直播
中国之声中国之声 · 云听
① 歌单 · 推荐结构和你的歌单页完全一致,只是数据换成电台:大卡是 m-feature,下面是 m-grid2 两列卡,胶囊里是当前直播台。
9:41
歌单
歌单

云听 · 全部

网易云音乐QQ 音乐酷狗云听
全部类型地区
全部热门新闻财经生活交通综艺音乐故事民族
央广
共 19 个电台
经济之声正在直播:天天 3.15
音乐之声正在直播:中国 TOP 排行榜
中国交通广播正在直播
北京交通广播正在直播
北京音乐广播正在直播
文艺之声正在直播
② 歌单 · 分类分组 chip(全部 / 类型 / 地区)+ 分类 chip 两行,沿用 m-chips;下面还是同一套 m-grid2。选「地区」时换成 31 个省市。
9:41
中国之声
中国之声

云听 · 直播 + 今日节目单
23 首 · 按聚合策略播放

直播 · 新闻有观点直播 正在播出
档案揭秘00:00 · 30 分钟
记录中国00:30 · 30 分钟
昨日新闻重现01:00 · 2 小时
新闻有观点(重播)03:00 · 1 小时
朝花夕拾(重播)04:00 · 1 小时
云听清晨05:00 · 1 小时
国防时空06:00 · 30 分钟
③ 电台详情m-colbg 取封面色的头部渐变、128dp 封面 + 标题、m-split 播放全部 + 圆角 tonal 收藏,下面是 m-seglist:2dp 缝、外层 16dp。第一条直播带 m-tag。

组件对照

界面上的部件当前代码云听怎么用
大标题 + 副标题 + 搜索PageScaffold / LargeFlexibleTopAppBar副标题 云听 · 为你推荐 / 云听 · 全部
平台 chip 行ProviderChips → MusicChip末尾多一个「云听」,选中是 16dp 圆角 + 对勾
推荐 / 分类 / 听歌模式ConnectedToggleGroup云听没有听歌模式(canListen=false),只有推荐和分类两段
通栏大卡FeaturePlaylist(176dp,28dp 圆角)放正在直播的节目:标题是节目名,说明行写台名和时间
卡片网格DiscoverCard + GridRow电台卡,副标题放「正在直播:…」
分类 chipCategoryChips + MusicChip类型 10 个 / 地区 31 个,都是 PlaylistCategory
播放全部PlayAllSplitButton直接播第一条(直播)
曲目行SongRow(segmentShape 分段)直播行用 badges 槽位标「直播」,其余是回听节目
悬浮播放条MiniPlayer(64dp 胶囊)直播项的底边进度条留空
底部导航ShortNavigationBar不改

直播播放语义:现在的代码会怎么表现

电台没有终点,拿 duration=0 往现有播放器里一塞就会出问题,这一节写清楚要补什么。组件不动,只补一个 live 判断。

位置现在会发生什么要做的处理
时长onEvents 里 C.TIME_UNSET 会退回 song.duration*1000 = 0(MusicViewModel.kt:3466)新增 isLive = player.isCurrentMediaItemLive,存进 state;直播项不再读 duration
进度startProgress 在 UNSET 时 bounded = position,位置一直涨;WavySeekBar 的 fraction 是 0,左边时间却跳到 12:04,读屏念「已播放 12:04,共 00:00」直播时把进度区换成「直播中」文案、隐藏总时长、禁用拖动与左右键;spokenPosition 只在非直播时更新
MiniPlayer底边 2dp 进度条 duration > 0 才画,直播自然是空条不用改
队列 / 自动下一首直播不会触发 STATE_ENDED,所以不会自动播下一首;「播放全部」从第一条直播开始,今天 23 期节目要手动点到「播放全部」仍从直播开始(点电台就是听直播),但直播行不参与自动续播;想听节目点对应行或按下一首
MediaSession / 通知栏进度按 100ms 外推后推送,锁屏会看到一个永远不结束的进度直播时不推 position、duration 置为未知,通知栏只显示「直播 · 电台名」
播放上报 / 听歌时长PlayReportHub.frame 与 accumulateListenTime 会把直播当成一首歌计数(影响汽水听歌达标)直播项跳过上报和时长累计,回听节目照常
循环 / 随机对直播没有意义,按钮仍然在直播时隐藏这两个键,只留上一台 / 播放 / 下一台
一句话:直播曲目是「没有时长的曲目」,播放器里所有跟时长相关的 UI 和上报都要按 isLive 跳过;队列、上一首/下一首、后台播放、通知这些现有能力原样可用。
05

落地清单

文件改动
data/native/YuntingProvider.kt新增:签名请求、四个接口、NativeProvider 实现
data/native/NativeProviders.kt注册 provider(id yunting,name 云听)
res/drawable/ic_provider_yunting.xml本方案的图标
ui/components/ProviderIcon.ktid / 名字两处映射
ui/player/*、playback/*直播项:不推送进度、不计入总时长(界面组件不改)
aggregate/Models.kt默认启用平台不必改:云听没有搜索,不进聚合搜索

风险与边界