结论
接口是云听网页版自己用的那套签名接口,参数和签名方式都写在页面 JS 里,没有用户态、没有鉴权头、没有设备指纹。能做,而且不用改动现有歌单页的交互。
图标
官方图标是红底圆角方块里的「云听」连笔字标。按现有平台图标的规格把它描成单色矢量:24dp 画板、实心剪影、一个 path,放进 36dp 圆角色块(图标占 70%),颜色跟 onSurfaceVariant / primaryContainer 走。字形照官方原样提取,只做单色化,不重新设计。
官方字标 · 单色化
采用从官方字形提取外轮廓,笔画统一到 约 1.3dp(和 QQ、酷狗那批图标同重),整块 20 × 15dp 居中放在 24dp 画板里。36dp 列表位清楚可辨,20dp 小头像上只剩骨架——和官方图标本身在小尺寸下的表现一致。
此前自造的「云 + 声波」
未采用这套是照「云 + 声波」自己画的标记,和官方字标不是一回事,已废弃——平台图标应该照官方标志单色化,不该另造符号。
和现有图标并排
规格与代码
| 项 | 值 |
|---|---|
| 画板 | 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("云听")。
接口:已实测的部分
四个 GET 接口撑起全部功能,域名 ytmsout.radio.cn。签名放在 header 里:把 query 参数按 key 排序拼成 k=v&k=v,再接 ×tamp=<毫秒>&key=<网页 JS 里的常量>,取大写 MD5。
| 用途 | 路径 | 参数 | 返回 |
|---|---|---|---|
| 类型 | /web/appCategory/list/all | 任意占位参数(无参会被网关拦) | 10 个类型:全部 / 热门 / 新闻 / 财经 / 生活 / 交通 / 综艺 / 音乐 / 故事 / 民族 |
| 地区 | /web/appProvince/list/all | 同上 | 32 项:国家 + 31 个省级地区(含新疆兵团) |
| 电台列表 | /web/appBroadcast/list | categoryId、provinceCode,两个维度可组合 | 电台数组,单次最多 74 条(山东全省) |
| 可回听电台 | /web/appBroadcast/listenbackList | provinceCode=''(空串才是全国) | 19 个央广台;省级传编码返回空 |
| 节目单 | /web/appProgram/listByDate | date=YYYY-MM-DD、broadcastId | 当天节目:名称、起止时间、回听地址、封面 |
// 签名示例(Kotlin 侧照抄即可,key 就是网页 js/api.js 里的常量) val sorted = params.toSortedMap().entries.joinToString("&") { "${it.key}=${it.value}" } val sign = md5("$sorted×tamp=$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 条,代价可以接受。形态:电台即歌单
不新增页面类型。云听复用 PlaylistsScreen 的平台 chip、分类筛选、歌单卡片和 CollectionScreen,只把数据换掉。
数据映射
| 云听 | myMusic 模型 | 字段 |
|---|---|---|
| 电台 | Playlist | id=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 用 |
| 回听节目 | Song | id=节目 id,name=programName,artist=台名,album=节目日期,duration=(endTime-startTime)/1000,url=playUrlHigh,ext="m4a",cover=image,extra["replay"]="1" |
| 类型 / 地区 | PlaylistCategory | group="类型" | "地区",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 |
界面稿
视觉直接照 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 底栏。云听只改内容。
歌单
云听 · 为你推荐
正在直播
热门电台
m-feature,下面是 m-grid2 两列卡,胶囊里是当前直播台。歌单
云听 · 全部
央广
共 19 个电台m-chips;下面还是同一套 m-grid2。选「地区」时换成 31 个省市。中国之声
云听 · 直播 + 今日节目单
23 首 · 按聚合策略播放
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 | 电台卡,副标题放「正在直播:…」 |
| 分类 chip | CategoryChips + 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 会把直播当成一首歌计数(影响汽水听歌达标) | 直播项跳过上报和时长累计,回听节目照常 |
| 循环 / 随机 | 对直播没有意义,按钮仍然在 | 直播时隐藏这两个键,只留上一台 / 播放 / 下一台 |
isLive 跳过;队列、上一首/下一首、后台播放、通知这些现有能力原样可用。落地清单
| 文件 | 改动 |
|---|---|
data/native/YuntingProvider.kt | 新增:签名请求、四个接口、NativeProvider 实现 |
data/native/NativeProviders.kt | 注册 provider(id yunting,name 云听) |
res/drawable/ic_provider_yunting.xml | 本方案的图标 |
ui/components/ProviderIcon.kt | id / 名字两处映射 |
ui/player/*、playback/* | 直播项:不推送进度、不计入总时长(界面组件不改) |
aggregate/Models.kt | 默认启用平台不必改:云听没有搜索,不进聚合搜索 |
风险与边界
- 地址有时效:直播 token 是签发时刻生成的,队列里放久了会失效;
playableStream()播前重取一次即可,失败再回退。 - 明文 HTTP:返回的流地址是
http://,仓库已开usesCleartextTraffic;实测把 scheme 换成 https 也能拿到 200。 - 回听只有央广:31 个地区传
provinceCode回听列表都是空,地区台只有直播一条。 - 节目不是单曲:一段 30–150 分钟,标题是节目名;队列里连播会接着放下一段节目,符合广播收听习惯。
- 没有搜索、歌词、下载:
ProviderInfo全部关闭;后续如果要找台,用「地区 + 类型」两步筛选足够。 - 不做的事:云听的听书/音频专辑接口(
appAlbum)、账号与会员、节目往期(pageByColumn)——都留作后续,不进这一版。