关键决策与 v2 差异
v2 为了"稳"把 Google TV 最有辨识度的东西都禁掉了:背景跟随内容、玻璃层次、随内容变化的颜色。v3 把它们拿回来,用渲染分级保证低端盒子也能跑。功能与工程部分保留 v2 中验证过的结论,并按 2026-11 的一手资料修正了版本、权限和 API 细节。
MaterialExpressiveTheme、MotionScheme、MaterialShapes。dev.chrisbanes.haze 2.0.1,API 为 hazeSource / hazeBlur / HazeBlurStyle。按三档渲染:T1 实时模糊,用于 API 31+ 且性能达标的设备;T2 冻结模糊,背景图变化时预先模糊一次,玻璃控件按自身坐标取样,覆盖 API 23–30 和视频背景;T3 纯色兜底。不启用 Haze 的实验性 RenderScript 路径,因为它在 Lazy 列表里有已知的不刷新问题。MaterialExpressiveTheme、MotionScheme、ButtonGroup、Wavy 进度条和 Emphasized 字体已经是正式 API;MaterialShapes 和 LoadingIndicator 仍需 opt-in。代价是在 minSdk 提到 24 之前,material3 不能再升级。详见 §15。android.permission.READ_TV_LISTINGS,能读到所有 App 的 searchable=1(默认值)频道、预览节目和 Watch Next;查询不能带 selection,过滤在本地做。频道审批状态由桌面自己维护。API 23–25 可选用 NotificationListener 读取旧版推荐通知。OEM 版使用 ACCESS_ALL_EPG_DATA。Google TV 设备上新的"继续观看"走 Engage SDK,第三方读不到,界面必须能接受这一行为空。BIND_APPWIDGET 或用户授权,而 AOSP TvSettings 没有授权确认界面。方案按顺序尝试五条路径:已获授权 → 系统确认界面(设备有就用)→ Shizuku 一键授权 → 一次性 ADB 命令 → oem 变体直接持有权限。同时接收 App 发起的"添加到主屏幕"请求(API 26+)。API 31+ 用 setColorResources() 让小组件跟随桌面配色,只在主题稳定后调用。内置 Compose 小组件作为补充。详见 §12。相对 v2(GPT 版)的改动
| 维度 | v2 做法 | v3 做法 | 依据 |
|---|---|---|---|
| 背景 | 固定中性深色,禁止视频与海报背景 | 焦点内容背景图;主页空闲后播放航拍;壁纸 | Google TV 沉浸式列表的焦点背景;aerial-tv 在磁贴后播放航拍 |
| 玻璃 | 明令禁止 | Haze 2 三档材质,只用于界面框架层 | 需求;按设备分级保证帧率 |
| 配色 | 固定蓝紫,API 31+ 才可选系统动态色 | 全 API 级别自主取色,HCT 色调保证对比度 | 需求;TV 版 SystemUI 未接入壁纸取色 |
| 顶栏 | 推荐 / 应用 / 小组件 + 文字按钮 | 文字 Tab + 玻璃胶囊工具组 + 时钟 | Google TV 2025 主页改版 |
| 应用行 | 56dp 圆角方块,名称常显 | 70dp 圆形图标,焦点时变 Cookie 形并显示名称;应用页用 banner 网格 | Google TV 自 2024-02 起用圆形图标 |
| 焦点 | 1.03× + 3dp 描边 | 卡片 1.1×、按钮 1.05×;双层光环 + 局部取色光晕 + 弹簧 | Android TV 焦点规范(1.025 / 1.05 / 1.1)+ M3E |
| 动效 | 按毫秒时长定义 | M3 弹簧 token(阻尼比 / 刚度) | M3E MotionScheme |
| M3E 实现 | — | material3 1.5.0-alpha25:MaterialExpressiveTheme、MotionScheme、MaterialShapes、Wavy 进度条 | 最后一个 minSdk 23 的 1.5.0 版本 |
| Channel(API 23–25) | 不支持 | 可选读取推荐通知 | 旧版推荐行就是通知机制 |
| Channel(API 26+) | "尽力展示",可见范围未写明 | 按源码写明可见范围:自己的 + searchable=1;不能带 selection;本地审批表 | AOSP TvProvider 源码 |
| 小组件 | M3 描边外框;未处理绑定授权 | 第三方小组件为核心功能:选择器、五级绑定授权、网格画布、方向键内部交互、配色映射 | AOSP AppWidgetServiceImpl、TvSettings 源码 |
以下 v2 内容在 v3 中保留并精简:先过技术闸门再承诺界面、本地缓存优先、包可见性、Widget 生命周期、手机整理的安全基线、媒体版权闸门。
Google TV 结构拆解
学的是 Google TV 的信息结构和焦点行为,不复刻它的品牌元素、Gemini 入口和内容推荐服务。Google TV 的电视端界面目前只有部分设置页用了 M3E 元素(据 9to5Google 对 Android TV 16 的报道),所以"Google TV 结构 + M3E 语言"是本方案的组合,不代表 Google 官方的现状。
| 元素 | Google TV 现状 | 本方案 |
|---|---|---|
| 顶栏 | 2025-09 起:Home / Live / Apps;搜索、屏保、快速设置图标放在胶囊组里;最左头像菜单收纳 Watchlist、Library、Your services;2026-03 起 Gemini 成为左上角 Tab | 主页 / 应用 / 小组件;胶囊组放搜索、环境模式、快速设置;不设头像和账号:片单是主页的一行,隐藏的应用在桌面设置里恢复。不做 Live(本方案不做直播 EPG),不做 AI 入口 |
| 精选轮播 | 顶部 featured carousel | Hero 精选。来源依次为:已批准频道的精选、Watch Next 第一项、本地媒体。左右键切换,焦点停在 Hero 时每 9 秒自动切换 |
| 你的应用 | 2024-02 起改为圆形图标;banner 用在 Apps 页 | 主页圆形图标行,最多 10 个;应用页为 banner 网格,4 / 5 / 6 列可选;没有 banner 的应用用图标加名称合成 |
| 焦点与背景 | 官方 TV 规范:缩放 1.025 / 1.05 / 1.1,glow 2–32dp;沉浸式列表中焦点卡片 1.1 倍并加边框,背景随焦点更新 | 卡片 1.1、按钮 1.05、Tab 1.07;背景在焦点停留 260ms 后交叉淡入 |
| 快速设置 | 右侧面板 | 右侧玻璃面板,M3E 连接式按钮组与粗轨滑块(§14) |
| 屏保 | Ambient 屏保,支持照片与 AI 生成图 | 环境模式 + DreamService,片源为航拍、照片或时钟(§13) |
"焦点卡片放大与标题显示方式、背景是否随焦点变化"在 Google TV 主页本身没有找到一手说明;本方案依据的是 developer.android.com 的 TV 焦点系统与沉浸式列表规范。
Material 3 Expressive 的电视化
M3E 来自 Google 的 46 项研究、超过 18,000 名参与者,结论之一是关键元素的识别速度最多可快 4 倍。它本身面向触屏;放到十英尺距离、用方向键操作时,能直接迁移的是动效、形状、字重和色彩,交互状态要改成以"焦点"为中心。
动效:两套弹簧 token
| Token | Standard(阻尼比 / 刚度) | Expressive(阻尼比 / 刚度) | 本方案用在哪里 |
|---|---|---|---|
| spatial fast | 0.9 / 1400 | 0.6 / 800 | 卡片、按钮、Tab 获得焦点时的缩放(Expressive,可见轻微回弹) |
| spatial default | 0.9 / 700 | 0.8 / 380 | 图标 Cookie 形变、按钮组宽度变化;行内横向滚动用 Standard,避免整行晃动 |
| spatial slow | 0.9 / 300 | 0.8 / 200 | 行纵向切换、快速设置面板滑入 |
| effects fast / default / slow | 1.0 / 3800 · 1600 · 800 | 同 Standard | 透明度与颜色,无回弹。环境色另设 1.0 / 200(约 600ms,方案自定) |
数值与 material3 1.5.0-alpha25 源码中的 ExpressiveMotionTokens / StandardMotionTokens 逐项一致,代码直接使用 MotionScheme.expressive()。原型右侧的"动效方案"可切换 Expressive、Standard 和减弱三种,曲线由这些参数实时采样生成。
形状、字重与组件
形状
- 圆角刻度:0 / 4 / 8 / 12 / 16 / 20 / 28 / 32 / 48 / 全圆,加粗的是 M3E 新增档位。
- 内容卡片静止时 16,获得焦点变 24,按下变 12。面板 28–32,Hero 遮罩 48。
- MaterialShapes 共 35 个预设。本方案只在两处使用:应用图标焦点形变(圆 → Cookie9Sided)、启动加载指示器(多形状循环形变)。
字重与组件
- 15 个 Emphasized 样式,比基线重一档。Hero 标题、页标题使用 Emphasized。
- CJK 字体在老设备上没有可变字重,Emphasized 映射为 700 / 800;拉丁字母可选内置 Roboto Flex 以获得宽度轴。
- 按钮组(Hero 操作)、Wavy 进度条(继续观看、正在播放)、连接式按钮组(快速设置)、LoadingIndicator(启动应用、绑定小组件)。
material3 1.5.0-alpha25 里的 Expressive API
| API | alpha25 状态 | 本方案用法 |
|---|---|---|
MaterialExpressiveTheme | 正式 | 根主题。它默认用亮色的 expressiveLightColorScheme(),所以必须显式传入动态生成的暗色 ColorScheme |
MotionScheme.expressive() / standard() | 正式 | 所有动效从 MaterialTheme.motionScheme 取;行内长距离滚动单独用 standard() |
Typography 的 *Emphasized | 正式 | Hero 标题、页标题、浏览信息头 |
LinearWavyProgressIndicator | 正式 | 继续观看、正在播放;不可聚焦,可直接用 |
ButtonGroup | 正式 | 不直接用:源码里宽度动画(EnlargeOnPressElement)只响应按下,电视需要跟随焦点。改为自建 TvButtonGroup,复用 fastSpatialSpec() |
MaterialShapes + toShape() | 实验(opt-in) | 应用图标焦点形变(Morph) |
LoadingIndicator | 实验(opt-in) | 启动应用、绑定小组件时的等待提示 |
| Button、Slider 等可交互组件 | 正式 | 焦点场景不直接用(§10),只借用颜色和形状 token |
上面的样例使用与原型相同的实时配色,在原型里移动焦点时会一起变色。
aerial-tv 借鉴
依据 2026-11-02 的 main 分支(commit 6182cf2,版本 1.79 / versionCode 102;Lite 模块 1.15)的源码与清单文件。README 与 ARCHITECTURE.md 有几处落后于代码,以代码为准。aerial-tv 的首页本来就是"用户挑选的应用放在玻璃底板上、主页静止后航拍视频在磁贴后面播放",v2 把这部分去掉,偏离了参考项目。下表逐项列出它的能力和在本方案中的去向。
| aerial-tv 能力 | 实现要点(aerial-tv 文件) | v3.3 去向 |
|---|---|---|
| 首页:用户挑选的应用一排放在玻璃底板上,下方为全部应用;每排 4 / 5 / 6 个,单排或双排;按用户排列,不统计启动次数 | Compose 自绘主题,未使用 material3 与 androidx.tv(HomeScreen.kt、LayoutStore.kt) | §06:结构换成 Google TV 的内容行;每排数量与单双排作为应用页的个性化选项保留 |
| 航拍壁纸:主页静止后影片在磁贴后面播放,停下时当前帧成为静态壁纸,玻璃与网格跟着这一帧 | 影片在窗口下方的 SurfaceView(LiveWallpaper.kt、Wallpapers.kt) | §13 环境模式;这一帧同时用于取色和 T2 冻结玻璃 |
| 真机抓帧:PixelCopy 在小米 MStar 机型上得到全黑,在 TCL MT5879 上抓 4K 帧要 0.6–1.7 秒 | 一次超过 150 ms 即不再使用;回退为从 1080p H.264 文件取最近的关键帧(MediaMetadataRetriever);TCL 的 MPEG4 解析器会卡死,按开机次数记录(LiveWallpaper.kt) | §13"真机抓帧" |
| 流式播放与缓存 | 缓存上限为可用空间的 1/4,限定在 64 MB–3 GB,LRU 淘汰,边播边写;定时屏保开始前 8 秒预取下一段开头 6 MB(AerialStream.kt) | §13"画质、缓存与失败处理" |
| 画质:自动 / 1080p / 4K / 4K 杜比视界 | 解码出错时本进程降一档;连接 5 秒、读取 8 秒超时,8 秒没有画面跳下一段,连续 3 次失败改放照片(Aerials.kt、AerialStream.kt、Screensaver.kt) | §13"画质、缓存与失败处理" |
| 影片集合与衔接:只轮换 3 段,每天换入一段;壁纸是航拍时,屏保沿用壁纸的画质接着播 | AerialSet.kt、Screensaver.kt | §13"从环境模式到屏保" |
| 解码器交接 | 主页暂停后,影片最多再占用解码器 2.5 秒以取得静态帧;Mi TV 4 离开 HDMI 输入后立即打开解码器会整机重启,改为等待(LiveWallpaper.kt、FilmAfterInput.kt) | §13"解码器交接" |
| 屏保:航拍、照片、时钟、自己的视频四种,在固件屏保之前启动 | 自带屏保与 DreamService;系统屏保两次黑屏超过 2 秒即视为失效(Screensaver.kt、AerialDream.kt、SystemScreensaver.kt) | §13"屏保兜底";快速设置"立即启动"(§14) |
| 壁纸来源:必应每日一图(保留最近 8 张)、14 张内置图、手机上传的照片(可每天轮换)、自动(取焦点应用的画面)、航拍、自己的视频、信号源画面 | 14 张内置图在电视上程序绘制,不是图片素材(Wallpapers.kt、Gradients.kt) | §07 来源表;必应见 §13"必应每日一图"与 §20 风险;内置壁纸由本方案自绘 |
| 自己的视频:从手机页上传,作壁纸或屏保 | 请求体直接流式写入;单个 ≤ 2 GB,总量 ≤(已存 + 可用)的一半并留出 1 GiB,最多 64 段(ScreensaverVideos.kt、VideoWallpaper.kt、PhoneServer.kt) | §13"自己的视频"、§14"手机整理页" |
| 信号源画面作壁纸 | TvView 播放系统列出的输入,不出声;附加后 0.8 秒调台,弱信号时在 3 / 5 / 10 / 15 / 20 / 30 秒重试(SourceWallpaper.kt) | §14"HDMI 实时画面作壁纸"、§06 背景状态 |
| 整理:移动、文件夹、隐藏、卸载 | 文件夹磁贴为 3×3 缩略图,电视上用预设名称,手机上可改名,清空后消失(FolderView.kt、LayoutStore.kt) | P0 保留;§06"文件夹" |
| 开机 / 唤醒时打开指定的输入或应用 | 每次开机、每次唤醒各一次,亮屏期间不重复(BootOpen.kt) | §14"信号源补充" |
| HDMI 固定到主屏,可选设备图标(游戏机、机顶盒等),可改名 | SourceLooks.kt | P0;信号源作为圆形图标进入应用行,使用按设备类型预设的种子色(§14) |
| 信号源列表与信号源键 | TvInputManager;海信、康佳、XGIMI(MStar)走各自的固件接口;TV_INPUT 键打开输入列表,固件自带面板的机型让给固件(HomeScreen.kt、HisenseSources.kt、KonkaSources.kt、XgimiMstarSources.kt) | §10 按键表、§14"信号源补充";固件私有接口只放进 oem,机型差异放进 devices.json(§16) |
| 手机扫码整理:改名、自定义图标、照片与视频上传、天气城市、诊断 | NanoHTTPD + ZXing;固定端口 8790,持久的 8 位令牌,应用运行期间一直监听;局域网地址跳过 VPN 与 198.18.0.0/15(PhoneServer.kt、AppNames.kt、CustomIcons.kt) | §14"手机整理页";本方案仍用随机端口、一次性令牌与 10 分钟会话(自己的选择) |
| 控制中心:信号源、网络、设置、音量、亮度、个性化 | 亮度写 Settings.Global 或厂商的设置项,需要 WRITE_SECURE_SETTINGS(ControlCenter.kt、Brightness.kt、MitvPanel.kt) | §14 右侧玻璃快速设置面板;亮度只在 oem |
| 儿童模式、长辈模式与家长 PIN | PIN 为 4 位数字或 4 个方向键,SHA-256,解锁 10 分钟;长辈模式每排 4 个;无障碍服务开启时由它拦截,否则用 UsageStats 每 2 秒轮询,提醒退化为 Toast(KidsMode.kt、KidsWatch.kt、Restricted.kt、PinPad.kt、UsageFront.kt) | §14"儿童模式、长辈模式与家长 PIN";standard 不用无障碍服务 |
| 天气 | Open-Meteo 预报与地名检索;内置中国城市表(GeoNames,CC BY 4.0);未设城市时用 ipinfo.io 推测;3 小时过期,最多每小时刷新(Weather.kt、Cities.kt) | §12"内置时钟与天气" |
| 农历与 24 小时制 | 内置 1901–2100 农历表;除非系统设为 12 小时制,否则用 24 小时制(Lunar.kt、HomeChrome.kt) | §12"内置时钟与天气"、§13 时钟、§09 字号 |
| 界面按键音效 | AudioManager.playSoundEffect,只在 250 ms 内按过方向键时发声(Sounds.kt) | §10"按键音效" |
| 触屏与鼠标 | 点按、长按菜单、点浮层外关闭;遥控器按下过程中触摸不打断(Pressable.kt) | §10"触屏与鼠标" |
| 应用内更新 | 每 3 小时读版本文件,失败按 30 秒 / 2 / 10 / 30 分钟退避;显示已装版本之后的更新说明;校验包名、签名、versionCode;Android 9+ 用 PackageInstaller 会话并附带 .dm,12+ 条件满足时免确认,否则 ACTION_VIEW + FileProvider;首次启动发现未编译时重装一次(UpdateCheck.kt、Updates.kt、ReleaseNotes.kt、SelfCompile.kt) | §16"分发与更新"、§17;不做自动重装 |
| 诊断 | 最近 150 行日志、600 帧帧时间、设备与固件信息;IP、MAC、令牌打码(DiagLog.kt、FrameStats.kt、Diagnostics.kt) | §14"诊断导出" |
| 机型修正数据 | devices.json,单台电视可用 ADB 广播覆盖(DeviceProfile.kt、ConfigReceiver.kt) | §16"机型修正数据" |
| 刷新率与内存回收 | API 31+ 选可无缝切换的最高刷新率(≤ 120Hz);内存紧张时清图片与磁贴缓存,UI_HIDDEN 时不清(MainActivity.kt) | §17"启动、刷新率与内存" |
| 设备分级 | 低内存标志、内存 < 1.8 GB、Android ≤ 6 或 ≤ 2 核时减少效果(DeviceClass.kt) | §17 档位判定(已有) |
| 默认桌面兼容 | HOME 过滤器同时声明 COMMON_HOME 与 LAUNCHER,另有 HOME.CC 过滤器和 TCL 的 meta-data(app/src/standard/AndroidManifest.xml) | §14"设为默认桌面与 HOME 兼容" |
| 技术栈:compileSdk 36、minSdk 21、Compose BOM 2025.11.01(1.9.5)、Media3 1.8.1 | 注释写明 Compose 1.10 与 Media3 1.9 把 minSdk 提到 23(app/build.gradle.kts) | §15:本方案 minSdk 23,可直接用 Compose 1.12 与 Media3 1.9+ |
| 4K 界面画布(UhdCanvas) | Android 10+ 在 1080p 界面下按面板分辨率绘制主页;默认关闭,只在 ADB 强制时启用,源码注明尚未在电视上验证(UhdCanvas.kt) | 不采用:默认关闭,没有实机结论 |
| guard 守护 | guard flavor 只去掉 HOME 过滤器;HomeGuardService(无障碍服务接管主页键)、RevivalListener(通知监听拉起)、RevivalSync(同步适配器 + 账户)和 BootReceiver 都在主清单里,standard 包同样包含(app/src/main/AndroidManifest.xml、HomeGuardService.kt) | 有意不做:standard 不用无障碍服务、通知监听或账户保活;开机目标在桌面启动时判断,不需要开机广播 |
| CEC Home | 直接读写 Amlogic 的 /dev/cec,单一机型手动开启(CecHome.kt) | 有意不做:依赖驱动设备节点,不可移植;HDMI-CEC 交给系统,oem 可用厂商接口 |
| Lite | 独立模块 lite/(包名 app.orchard.tv.lite,minSdk 14,纯 View) | 有意不做:本方案 minSdk 23,只维护一套界面 |
| Apple 航拍影片 | 由 Apple 的服务器直接播放(Aerials.kt) | 有意不做:没有发行许可;片源改为自有或已授权内容(§13) |
| 第三方图标库 | 网站提供热门应用的 16:9 成品图,每天最多读两次索引(IconLibrary.kt) | 有意不做:图片使用各应用的商标与美术素材,许可不明;只保留用户自己上传的图标(§14) |
相对 v3.2 的更正
- 版本基线:由 74b135d 更新到 6182cf2。
- guard:v3.2 把无障碍服务和通知监听写成 guard 变体独有。实际上 guard flavor 只去掉 HOME 过滤器,这些组件都在主清单里。
- Lite:是独立模块,不是 productFlavor。
- 手机整理:aerial-tv 用固定端口 8790 和持久令牌,应用运行期间一直监听。v3.2 §14 的"随机端口、一次性令牌、10 分钟会话"是本方案自己的选择,不是参考做法,现已分开写。
- 4K 界面画布:UhdCanvas 默认关闭,v3.2 把它列为实现要点;本方案不采用。
- 内置壁纸:14 张是程序绘制的,不是图片素材。
- 首页排序:aerial-tv 没有启动次数统计,首页按用户排列。
aerial-tv README 原文:"航拍影片版权归 Apple,由 Apple 的服务器直接播放"。本方案的发行版本只使用自有、已授权或许可明确允许的影片与图片,上线前需要内容清单、许可凭证和下架替换机制。必应每日一图只按必应的壁纸授权在本机显示(§13、§20)。参考仓库为 MIT 许可,Inter 字体为 OFL;本文档不包含其源代码。
设计语言:Ambient Expressive
一句话:桌面自己不带颜色,颜色来自此刻焦点所指的内容;玻璃只承载界面框架,不承载内容。
五条原则
- 内容即环境。背景始终是焦点项的背景图、航拍或壁纸。
- 焦点即主题。全局配色跟随焦点停留结果;焦点光环用焦点项自己的局部配色,没有延迟。
- 玻璃只给框架。顶栏胶囊、快速设置、小组件外框、Toast 用玻璃;海报和应用图标本身是内容,不加玻璃。
- 弹性分层级。位移和缩放用 spatial 弹簧,可以回弹;颜色和透明度用 effects 弹簧,不回弹;长距离滚动用 Standard,不晃动。
- 十英尺可读。对比度由 HCT 色调差保证,原型中 onSurface 对 surface 约为 14:1;状态从不只靠颜色表达。
颜色从哪里来
- Ambient 渐变:主色调色板 T12 从左侧压暗,surface(T6)从底部压暗。
- 玻璃 tint:surfaceContainer 50% 透明度 + primary 6%。
- 焦点光环:局部 primary T90(彩度上限 28);光晕为 T62,60% 不透明度。
- 主按钮 / 进度条:全局 primary T80,onPrimary T20。
- 文字:onSurface N90,onSurfaceVariant NV80。
T 后的数字为 HCT 色调(等于 CIELAB 的 L*),N / NV 分别指中性与中性变体色调板。
首页结构与状态
按 TV 规范以 960×540dp 设计,1dp 等于 MDPI 下的 1px。左右安全边距 48dp,上下 27dp;内容在安全区内,背景铺满全屏。
| 区域 | 位置与尺寸(dp) | 规则 |
|---|---|---|
| 顶栏 | y 22–66;Tab 高 38、字号 14.5sp;胶囊高 46 | 胶囊为 Chrome 玻璃;Tab 获得焦点时变为反色圆角块(onSurface 底),选中但无焦点时为 12% 底色 |
| Hero 精选 | x 48、y 98、宽 500;标题 48sp;描述 14sp 两行;按钮高 46 | 标题最多两行,超出用省略号;指示点 8dp,当前项拉长为 28dp |
| 浏览信息头 | y 86;标题 30sp;描述两行 | 展示焦点项的来源、标题、元信息和描述;Hero 收起后出现 |
| 继续观看 | 卡片 200×112(16:9) | 卡内显示标题,底部为 Wavy 进度;焦点时卡下方显示副标题 |
| 你的应用 | 圆形图标 70,间距 18 | 最多 10 个;焦点时 1.12 倍并变为 Cookie 形,下方显示名称 |
| 频道行 | 统一高 144;宽度按 PreviewProgram 的海报比例(16:9 / 3:2 / 4:3 / 1:1 / 2:3)计算 | 行头显示来源应用图标与频道名;每个已批准的频道占一行,默认最多 3 行 |
| 小组件速览 | 最多 4 个;按网格原尺寸,高不超过 2 行(原型统一画成 236×124) | 第三方小组件是实时的 AppWidgetHostView;完整网格画布在"小组件"Tab(§12) |
| 焦点基线 | 浏览态焦点行顶部固定在 y 236;行内焦点固定在 x 48 | 滚动到行尾后不再滚动,焦点在可见区内右移 |
行的顺序:Hero → 继续观看(无内容时隐藏)→ 片单(只存本机,空时隐藏;Hero 的「片单」按钮加入或移出)→ 你的应用(始终存在;没有内容行时它成为第一行)→ 已批准的频道(含系列应用的频道,§11)→ 小组件速览 → 行末的"管理频道"入口。没有任何合法内容来源时,不显示占位推荐,首页直接从应用行开始。
| 状态 | 背景 | 玻璃 | 取色来源 |
|---|---|---|---|
| Hero 展开 | 当前精选项的背景图;切换精选时交叉淡入 0.9 秒 | 顶栏胶囊 | 精选海报 |
| 浏览 | 焦点内容的背景图,停留 260ms 后切换;聚焦应用或小组件时背景不变 | 顶栏胶囊 + 底部渐进模糊带 | 焦点项 |
| 主页环境化 | 航拍或自己的视频(SurfaceView),或壁纸;界面框架淡出,只保留时钟 | 不使用实时模糊;时钟底板为 T3 色块 | 视频关键帧,每 8 秒一次 |
| 信号源画面 | 固定的 HDMI 输入实时画面(TvView,静音,§14);主页静止后界面框架淡出,规则同主页环境化 | 不使用实时模糊;返回界面时背景换成内置深色底图,T2 玻璃基于它 | 该信号源按设备类型预设的种子色 |
| 快速设置 | 不变,叠加 38% 黑色 | 面板玻璃,取全部来源 | 不变 |
| 屏保 | 本应用屏保或系统屏保(§13);从环境模式进入时沿用同一个播放器、片段与位置 | 无 | 不变 |
文件夹
- 磁贴:文件夹在"你的应用"行和应用页中各占一个位置。磁贴内按 3×3 排列前 9 个应用的缩略图标,下方显示文件夹名;焦点规则与应用图标相同(§09)。
- 新建与命名:整理模式下对应用选"移到文件夹" → "新建文件夹"。电视上从预设名称中选:"影音""游戏""工具""学习""其他";任意名称在手机整理页修改(§14)。
- 打开:OK 打开文件夹浮层,主页在后面模糊变暗,应用按主页磁贴尺寸排列;BACK 关闭,焦点回到文件夹磁贴。
- 清空即删除:最后一个应用移出后,文件夹自动消失。
动态取色系统
AOSP 的 TV 版 SystemUI 没有绑定负责壁纸取色的 ThemeOverlayController,所以电视上不能指望 dynamicDarkColorScheme(context) 随壁纸变化(据源码推断;Google TV 设备未证实)。桌面要自己完成从像素到配色的全过程,好处是 API 23 也能用。
来源与规则
| 来源 | 采样 | 计算时机与缓存键 | 用途 |
|---|---|---|---|
| 应用图标 | API 26+ 的自适应图标分别取前景层和背景层,背景层权重更高;API 23–25 取整张图标 | 包安装或更新时;键为 包名 + versionCode + lastUpdateTime | "应用图标"模式的全局主题;应用卡片的局部光环 |
| 应用 banner | 320×180 → 64×36 | 同上 | 应用页网格 |
| 海报 / 背景图 | 图片库解码时额外生成一份软件位图缩略图(硬件位图无法读取像素) | 首次加载;键为图片 URI 加 ETag 或修改时间 | "焦点内容"模式;Hero 遮罩 |
| 壁纸 | 整图;包括内置程序绘制图、必应每日一图(§13)、手机上传的照片 | 设置或更换壁纸时(含每日轮换);键为图片文件路径加修改时间 | "壁纸"模式;小组件颜色映射的默认来源 |
| 视频关键帧 | API 24+ 用 PixelCopy 从 SurfaceView 抓 192×108;API 23 使用片单里预先算好的 keyColor | 环境模式下每 8 秒或换镜头时;不缓存 | 环境模式主题,过渡放慢到约 1.2 秒 |
| 信号源 | 不取色 | 按设备类型预设(游戏机、机顶盒、回音壁等) | HDMI 图标;信号源画面作壁纸时的全局主题(§14) |
- 低彩度来源(灰、白、黑的图标,如原型中的"文件管理"):MCU 默认会回退到 #4285F4。桌面改为保持当前全局主题,只把局部光环换成中性色,避免焦点经过一个灰色图标时整屏跳成蓝色。
- 节流:焦点停留 180ms 才切换全局主题;按住方向键连续滚动期间不切换,松开 180ms 后再算;新旧种子色差 ΔE < 6 时跳过过渡。
- 变体:全局界面默认 TonalSpot,色相与内容一致、彩度克制;Hero 遮罩可以用 Content 变体,更贴近海报。Expressive 变体会把主色相旋转 240°,界面颜色不再跟内容同色相,只作为个性化选项。色彩规范版本跟随 MCU 的 SPEC_2025(MaterialKolor 6.0 的默认值)。
- 对比度:前景与容器靠色调差保证对比度(如 onPrimary T20 对 primary T80)。设置中提供对比度级别 0 / 0.5 / 1.0,对应 DynamicScheme 的 contrastLevel。
- 不取色的情况:儿童模式与长辈模式(§14)锁定固定种子色;用户关闭"动态配色"时使用固定种子色;DRM 视频帧抓取失败时保持原主题。
三个作用范围
全局主题
由焦点停留结果驱动,影响环境遮罩、玻璃 tint、主按钮、进度条和 Tab。经 180ms 节流后过渡约 600ms。
局部主题
每个卡片用自己缓存的种子色算出光环(P90)和光晕(P62),获得焦点时立即生效,不等全局主题。
小组件主题
API 31+ 通过 setColorResources 下发。这个调用会让小组件整体重新加载,所以只在全局主题稳定 3 秒以上、或壁纸/主题设置变更时调用。
当前种子色的色调板
种子 —
Primary
Secondary
Tertiary
Neutral
色块下的数字是色调(0 黑,100 白)。原型用 CIELAB-LCh 近似 HCT 来演示:色调维度与 HCT 完全一致,色相和彩度是近似值。生产代码使用 MCU 的 HCT。
代码骨架
// core:dynamiccolor —— 依赖 com.materialkolor:material-color-utilities(MCU 的 Kotlin 移植,包名以 6.0.0 为准)
class SeedExtractor(private val dao: SeedCacheDao, private val io: CoroutineDispatcher) {
suspend fun seedFor(key: SourceKey, load: suspend () -> Bitmap?): SeedResult = withContext(io) {
dao.get(key.value, ALGO_VERSION)?.let { return@withContext it.toResult() }
val bmp = load() ?: return@withContext SeedResult.Unavailable
val small = Bitmap.createScaledBitmap(bmp, key.sampleW, key.sampleH, true)
val px = IntArray(small.width * small.height).also { small.getPixels(it, 0, small.width, 0, 0, small.width, small.height) }
val opaque = px.filter { Color.alpha(it) >= 250 }.toIntArray()
val quantized = QuantizerCelebi.quantize(opaque, 128)
val ranked = Score.score(quantized, desired = 1, fallbackColorArgb = NO_SEED, filter = true)
val result = if (ranked.first() == NO_SEED) SeedResult.LowChroma else SeedResult.Seed(ranked.first())
dao.put(key.value, ALGO_VERSION, result); result
}
}
// 全局主题:一个 Animatable 驱动插值,颜色在绘制阶段读取,过渡期间不触发重组
class ThemeController(private val scope: CoroutineScope) {
private val progress = Animatable(1f)
var from: LauncherColors = Defaults; private set
var to: LauncherColors = Defaults; private set
fun colors(): LauncherColors = lerp(from, to, progress.value) // 在 drawBehind / graphicsLayer 中调用
private var job: Job? = null
fun onFocusSettled(seed: SeedResult, style: Variant) {
if (seed !is SeedResult.Seed) return // 低彩度:保持当前主题
val next = LauncherColors.from(SchemeFactory.dark(seed.argb, style, contrast = settings.contrast))
if (deltaE(next.primary, to.primary) < 6f) return
job?.cancel(); from = colors(); to = next
job = scope.launch { progress.snapTo(0f); progress.animateTo(1f, LauncherMotion.ambientColor()) }
}
}
Haze 玻璃材质
Haze 2.0.1 发布于 2026-09-29,所有 artifact 的 minSdk 都是 23,要求 Compose ui 1.12.1、Kotlin 2.4.20。2.0 删掉了 1.x 的 haze() / hazeChild() / HazeStyle,本方案按 2.x API 编写。
| Haze 在 Android 上的行为 | 事实 | 对本方案的影响 |
|---|---|---|
| API 31+ 且硬件加速 | RenderEffect 实时模糊 | T1 的基础 |
| API 30 及以下 | 默认关闭模糊,改画半透明 scrim;RenderScript 模糊需手动开启,官方标为实验性,且画面"几乎总会慢一帧以上" | 不开 RenderScript:内容位于子图层(Lazy 列表项、Pager)时模糊画面永久不更新(issue #1368,不修复) |
| SurfaceView | 无法被模糊;TextureView 可以 | 视频继续用 SurfaceView(保留 DRM 安全输出、HDR、电视全分辨率),视频上方的玻璃改走 T2 或 T3 |
| AndroidView | 官方说明:AndroidView 本身不保证像素能被捕获 | 玻璃不盖在 AppWidgetHostView 上方;小组件外框只模糊背景层 |
| 性能档位 | HazePerformanceMode.Performance / Balanced / Quality,默认 Balanced;可通过 LocalHazePerformanceMode 全局设置 | T1 设备默认 Performance,高端机可在设置中改为 Balanced |
材质 token
| 材质 | 用于 | blurRadius | 颜色效果(colorEffects) | noise | 取样来源 |
|---|---|---|---|---|---|
| Chrome | 顶栏胶囊、时钟底板 | 24dp | surfaceContainer 50% + primary 6% | 0.12 | 只取 backdrop |
| Frame | 小组件外框 | 20dp | surfaceContainer 45% | 0.10 | 只取 backdrop |
| Panel | 快速设置、对话框 | 36dp | surfaceContainerHigh 62% | 0.15 | 全部来源 |
| Toast | 启动提示 | 24dp | surfaceContainerHighest 70% | 0.10 | 全部来源 |
| Band | 内容行区域的底部渐隐带 | 16dp | 无;用 mask 渐隐 | 0 | 只取 backdrop |
| T3 兜底 | 所有材质 | — | fallbackColorEffect:surfaceContainerHigh 94% | — | — |
tint 颜色全部来自当前动态配色,所以玻璃会随焦点变色。可以在原型右侧切换 T1 / T2 / T3 对比效果。
三档渲染
| 档位 | 启用条件 | 实现 | 代价 |
|---|---|---|---|
| T1 实时 | API ≥ 31、硬件加速、设备等级 ≥ 中、背景不是正在播放的视频、未开省电 | hazeBlur,Performance 档;同时最多 3 块模糊区域 | GPU 每帧做模糊;面积越大越贵 |
| T2 冻结 | API 23–30;或 T1 设备上背景是视频帧;或 T1 实测掉帧 | 背景图变化时离屏生成一张模糊位图(API 31+ 用 RenderEffect 离屏;API 23–30 先降采样到 1/8 再做 StackBlur,或用自行编译的 Intrinsics Replacement Toolkit);每个玻璃控件在 drawBehind 中按自己的 positionInRoot 偏移绘制这张图,再叠 tint | 每次换背景约做一次 CPU 模糊;玻璃里看不到前景内容(顶栏和外框本来就只取背景,影响很小) |
| T3 纯色 | 低端设备、省电、用户关闭 | blurEnabled(false) + fallbackColorEffect,或者直接用 Surface | 几乎没有 |
val hazeState = rememberHazeState()
CompositionLocalProvider(LocalHazePerformanceMode provides tier.hazeMode) { // T1 设备默认 HazePerformanceMode.Performance
Box(Modifier.fillMaxSize()) {
AmbientBackdrop(Modifier.fillMaxSize().hazeSource(hazeState, zIndex = 0f, key = "backdrop"))
HomeContent(Modifier.fillMaxSize().hazeSource(hazeState, zIndex = 1f, key = "content"))
// L4:只取背景层,焦点动画不会让顶栏每帧重新模糊
val backdropOnly = HazeInput.Sources(hazeState, selection = HazeSourceSelection.Behind.where { it.key == "backdrop" })
TopBarPill(Modifier.glass(tier, backdropOnly, LauncherGlass.chrome(colors)))
// L6:模态面板,背后静止,取全部来源
if (quickSettingsOpen) QuickSettings(Modifier.glass(tier, HazeInput.Sources(hazeState), LauncherGlass.panel(colors)))
}
}
object LauncherGlass {
fun chrome(c: LauncherColors) = HazeBlurStyle {
blurRadius(24.dp)
noiseFactor(0.12f)
colorEffects(listOf(HazeColorEffect.tint(c.surfaceContainer.copy(alpha = 0.50f)), HazeColorEffect.tint(c.primary.copy(alpha = 0.06f))))
fallbackColorEffect(HazeColorEffect.tint(c.surfaceContainerHigh.copy(alpha = 0.94f)))
}
}
// T1 / T2 / T3 统一入口
fun Modifier.glass(tier: GlassTier, input: HazeInput, style: HazeBlurStyle): Modifier = when (tier) {
GlassTier.Live -> hazeBlur(input = input, style = style)
GlassTier.Frozen -> frozenGlass(LocalFrozenBackdrop.current, style.tintColors()) // 预模糊位图按 positionInRoot 偏移绘制
GlassTier.Solid -> hazeBlur(input = input, style = style.then { blurEnabled(false) })
}
tintColors() 与 frozenGlass 为项目内扩展,不属于 Haze。HazeInput.Backdrop(state) 是官方示例写法,会优先走平台原生 backdrop 路径(仅新版本平台且默认关闭),否则回落到 Sources;本方案直接用 Sources 以便按 key 选择来源。
组件与焦点规范
焦点状态至少同时用两种手段表达:缩放加外环,或形变加外环。外环宽度不小于 3dp,与相邻颜色的对比度不低于 3:1。颜色只做加强,不单独表达状态。
| 组件 | 静止 | 焦点 | 按下 | 弹簧 |
|---|---|---|---|---|
| 内容卡片 | 圆角 16,无描边 | 1.1 倍;圆角 24;外环 3dp(局部 P90)+ 光晕 32dp(局部 P62,60%);卡下出现标题 | 1.04 倍,圆角 12 | Expressive spatial fast |
| 应用图标 | 70dp 圆形 | 1.12 倍;圆 → Cookie9Sided;外环跟随形状;下方显示名称 | 0.96 倍 | 形变用 spatial default |
| 按钮组 | 高 46,全圆角 | 宽度加 20,圆角 → 14,1.05 倍;非主按钮反色;主按钮加双层外环 | 圆角 8 | 宽度用 spatial default,圆角用 fast |
| 顶栏 Tab | 纯文字;已选中项有 12% 底色 | 反色圆角块,1.07 倍 | — | fast |
| 胶囊内图标按钮 | 38dp 圆 | 反色,圆角 12,1.1 倍 | — | fast |
| 玻璃小组件 | 圆角 24 | 1.1 倍,圆角 30,外环 | — | fast |
| 快速设置磁贴 | 关:全圆胶囊;开:primaryContainer,圆角 16 | 外环,1.04 倍 | — | fast |
| 高对比焦点框(桌面设置中开关;长辈模式强制开启) | 同各组件 | 在各组件原有焦点表现之外,加 4dp 纯白外环,外侧再加 2dp 黑色描边;不随取色变化 | 同各组件 | 同各组件 |
字号(以 960dp 宽画布计)
| 角色 | 字号 / 行高 | 字重 | 说明 |
|---|---|---|---|
| Hero 标题 | 48 / 1.06 | 800(Emphasized) | 最多两行;拉丁字母可用 Roboto Flex 宽度 110 |
| 浏览信息头标题 | 30 / 1.15 | 800 | 一行 |
| 页标题 | 22 | 780 | 应用、小组件页 |
| 行标题 | 15 | 660 | 行标题后不放说明文字 |
| 正文 / 描述 | 14 / 1.6 | 400 | 最多两行 |
| 标签 | 12.5 | 600 | 元信息、胶囊标签 |
| 辅助文字 | 不小于 11.5 | 450 | 只用于非必要信息 |
| 时钟 | 16;环境模式 72 | 520;250 | 等宽数字,避免每分钟抖动 |
| 日期 · 农历 · 天气 | 13;环境模式 20 | 500;400 | 时钟下方一行;默认 24 小时制(§12);温度用等宽数字 |
必须设计的非理想状态
| 状态 | 呈现 | 焦点 |
|---|---|---|
| 首次启动 | 选择常用应用、取色来源、玻璃效果(按设备等级给出默认值);Channel 授权可以稍后进行 | 主按钮"完成",所有步骤可跳过 |
| 没有任何内容来源 | 隐藏 Hero 和内容行,应用行上移为第一行;背景改为壁纸 | 默认停在第一个应用 |
| 图片加载失败 | 用该来源应用的图标取色做实色占位,并显示标题 | 仍可打开 |
| 取色失败或低彩度 | 保持当前全局主题 | — |
| T1 掉帧 | 连续 3 秒 P95 帧时间超标时自动降为 T2,并提示一次 | 不打断当前焦点 |
| 无网络 | 使用缓存海报与缓存的种子色;不弹重试框 | 本地应用不受影响 |
遥控器与焦点引擎
不使用 androidx.tv 组件库。tv-material 1.1.0 和 tv-foundation 1.0.0(2026-05)都没有 Expressive 支持;官方提示把手机版 Material 组件直接用在电视上"未经优化",与 tv-material 混用会出现"意外行为"。因此 material3 1.5.0-alpha25 负责主题、动效、形状和不可聚焦的组件(Wavy 进度条、LoadingIndicator);所有随焦点变化的电视组件由 core:tvfocus 自建,动效参数取自 MaterialTheme.motionScheme。
- 列表:使用标准
LazyRow/LazyColumn/LazyVerticalGrid。TvLazy 系列在 2025-01 已被移除,官方建议用标准 Lazy 布局,并用BringIntoViewSpec实现焦点定位。 - 定位:行内焦点停在左侧 48dp 处,纵向焦点行停在 y 236。行尾不留空白,最后几项在可见区内移动焦点。
- 记忆:每行用稳定 key 记住最后的焦点项(应用用 ComponentName,节目用 来源包名 + 外部 ID,小组件用 appWidgetId)。纵向移动时回到目标行记住的那一项,与 Google TV 一致。
- 连按:长按方向键时最快每 80ms 移动一次;连按期间暂停背景切换和全局取色,松开后再更新。
- 恢复:项目消失时依次落到同行下一项、上一项、行标题、主页默认项;进程被杀后只恢复页面、行和 key,不恢复对话框、授权流程或拖拽中间状态。
// 行内焦点停在左侧 keyline(BringIntoViewSpec 为 ExperimentalFoundationApi,以锁定版本为准)
val KeylineSpec = object : BringIntoViewSpec {
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float {
val keyline = 0f // 内容区已有 48dp 内边距
return offset - keyline
}
}
CompositionLocalProvider(LocalBringIntoViewSpec provides KeylineSpec) {
LazyRow(contentPadding = PaddingValues(horizontal = 48.dp), horizontalArrangement = Arrangement.spacedBy(18.dp)) { /* … */ }
}
| 按键 | 默认行为 | 例外与保护 |
|---|---|---|
| 方向键 | 移动唯一焦点;Hero 内到最左或最右按钮后再按,切换上一个或下一个精选 | 边缘不循环;不穿透浮层 |
| OK | 激活;500ms 内去重 | 启动中显示形变加载指示器 |
| 长按 OK / MENU | 上下文菜单:移动、隐藏、卸载、频道管理、小组件设置;在无菜单的位置按 MENU 打开快速设置 | 不拦截系统保留行为 |
| BACK | 依次关闭浮层、退出整理模式、关闭文件夹、回到 Hero、回到顶栏 | 不形成循环 |
| HOME | 任何状态回到 Hero,焦点到主按钮;环境模式下先恢复界面 | 不新建播放器 |
| 播放 / 暂停 | 只在环境模式或预览视频可见时处理 | 其他情况不消费,不影响系统媒体键 |
| 信号源(TV_INPUT) | 打开快速设置的信号源页 | 固件自带信号源面板的机型不消费(devices.json,§16);儿童 / 长辈模式只列出允许的信号源 |
按键音效
- 声音:
AudioManager.playSoundEffect(int)。焦点移动用FX_FOCUS_NAVIGATION_UP / DOWN / LEFT / RIGHT,OK 用FX_KEY_CLICK,BACK 在 API 33+ 用FX_BACK,到边缘无法移动时用FX_KEYPRESS_INVALID。 - 跟随系统:这个重载按系统"按键音"设置(
Settings.System.SOUND_EFFECTS_ENABLED)决定是否发声,桌面不另设总开关。 - 只响应用户操作:最近 250ms 内收到过方向键、OK 或 BACK 的按下事件才发声。程序移动焦点(恢复记忆、项目消失后的落点、Hero 自动轮播)以及触屏、鼠标操作都不发声。
触屏与鼠标
- 点按:先把焦点移到被点的项,再按 OK 的规则激活(同样 500ms 去重)。鼠标左键相同。
- 长按:达到系统长按时长(
ViewConfiguration.getLongPressTimeout())打开该项的上下文菜单,等同长按 OK;鼠标右键相同。 - 拖动:行内横向、页面纵向都可拖动滚动,松手后按速度惯性滑动。拖动不移动焦点,背景与全局主题继续跟随当前焦点项。
- 浮层:点在浮层面板之外关闭浮层,等同 BACK;面板内的触摸不会穿透到下面的主页。
- 与遥控器混用:遥控器按键按下尚未抬起时,触摸不结束这次按压。
Android TV Channel
下面的结论来自 AOSP TvProvider 源码和 androidx.tvprovider 文档:第三方桌面拿到 READ_TV_LISTINGS 后能读什么、不能写什么。Google TV 设备上的数据量另有限制,界面设计时就要考虑"没有频道"的情况。
数据来源
| 来源 | API | 权限 | 能读到什么 | 限制 |
|---|---|---|---|---|
| A 预览频道 / Watch Next | 26+ | android.permission.READ_TV_LISTINGS,危险权限,运行时申请(常量被 @hide,代码里写字符串) | 本应用写入的行,加上所有 searchable = 1 的频道、预览节目、Watch Next;三张表该列默认值都是 1 | 查询只要带 selection 就抛 SecurityException;不能写 COLUMN_BROWSABLE(需要 ACCESS_ALL_EPG_DATA,signature|privileged) |
| B 旧版推荐通知 | 21–25 | 通知使用权(NotificationListenerService) | category == "recommendation" 的通知,用 androidx.recommendation 的 RecommendationExtender 解析 | 部分电视的设置里没有授权入口,需要 adb 或 OEM 预置;Android 10 及以下的低内存设备无法绑定监听服务 |
| C 系统级 | 26+ | ACCESS_ALL_EPG_DATA | 全部数据,可带 selection,可写 browsable | 仅 oem 变体(系统签名或预置 priv-app) |
| D 系列直连 | 23+ | 双向证书 SHA-256 固定 | myBV、mydv、mytv-android 导出的 Provider:控制接口,以及没有 TvProvider 时的行数据 | 只对系列应用;有 TvProvider 时数据仍走 A |
规则
- 整表读取,本地过滤:三张表各查一次,不带 selection,在内存中按频道分组。数据量通常是几百行,代价可以接受。
- 审批状态自己存:新建频道的 browsable 默认是 0,第三方桌面又不能写这一列,所以"哪些频道显示在主页"记录在桌面自己的
ChannelApproval表里。新频道出现时,在行末"管理频道"入口加角标,不弹窗打断。 - 用户移除节目:
PREVIEW_PROGRAM_BROWSABLE_DISABLED、WATCH_NEXT_PROGRAM_BROWSABLE_DISABLED是受保护广播,第三方桌面发不出,移除只在本地生效;系列应用通过直连回传(见下文)。 - App 请求显示频道:有两条路。
requestChannelBrowsable()(publishDefaultChannel()内部也会调用)由系统发出受保护广播CHANNEL_BROWSABLE_REQUESTED,逐个发给声明了接收器的应用,带频道 id 和请求方包名;桌面注册接收器即可收到(TvInputManagerService 源码)。ACTION_REQUEST_CHANNEL_BROWSABLE是 Activity Intent,桌面声明处理它的确认页,确认后写审批表并返回 RESULT_OK;系统桌面仍安装时由谁接收,需在 M0 验证。 - 刷新:在
TvContract的三个 URI 上注册 ContentObserver(只在前台注册),防抖 500ms,结果写入 Room;冷启动先显示缓存。 - 启动安全:
INTENT_URI来自其他 App 写入的数据。解析后把 package 固定为该行的package_name,清掉 URI 授权标志,捕获 ActivityNotFoundException。
Android 开发者文档写明 Watch Next 接入"必须通过质量认证才会在 Google TV 设备上显示";Engage FAQ 写明"所有新的继续观看接入都必须使用 Engage"。Engage 数据没有公开的读取接口,所以在 Google TV 设备上,TvProvider 里的数据可能很少。主页必须在没有 Hero、没有继续观看时也成立:应用行上移为第一行,背景改为壁纸(§06)。用系统特性判断是否为 Google TV 设备的方式(如 com.google.android.feature.AMATI_EXPERIENCE)待实机验证。
字段到界面
| 字段 | 界面 |
|---|---|
POSTER_ART_URI + POSTER_ART_ASPECT_RATIO | 卡片图;TYPE_CHANNEL 且没有比例时按 1:1 台标卡,底色取台标颜色;行高统一 144dp,宽度按比例:16:9、3:2、4:3、1:1、2:3、电影海报(1:1.441) |
THUMBNAIL_URI | 没有海报时使用;两者都没有时用来源 App 图标取色做实色卡 |
TITLE / EPISODE_TITLE / 季集号 | 焦点卡下的标题;浏览信息头标题 |
SHORT_DESCRIPTION / DESCRIPTION | 浏览信息头描述,最多两行 |
PREVIEW_VIDEO_URI | 焦点停留 1.2 秒后在卡片内静音预览(仅高档设备,§17);离开焦点立即释放 |
LAST_PLAYBACK_POSITION_MILLIS / DURATION_MILLIS | Wavy 进度条 |
WATCH_NEXT_TYPE + LAST_ENGAGEMENT_TIME_UTC_MILLIS | 继续观看排序与角标:继续、下一集、新内容、片单 |
频道 DISPLAY_NAME + buildChannelLogoUri() | 行头:来源 App 图标、频道名 |
LIVE / CONTENT_RATING / REVIEW_RATING | "直播"角标;元信息 |
WEIGHT | 行内排序,权重相同时按最近互动时间 |
class ChannelRepository(private val cr: ContentResolver, private val approvals: ChannelApprovalDao) {
// 无 ACCESS_ALL_EPG_DATA 时,带 selection 的查询会抛 SecurityException:整表读取后在本地过滤
suspend fun previewRows(): List<ChannelRow> = withContext(Dispatchers.IO) {
val channels = cr.readAll(TvContractCompat.Channels.CONTENT_URI, Channel.PROJECTION, Channel::fromCursor)
.filter { it.type == TvContractCompat.Channels.TYPE_PREVIEW }
val programs = cr.readAll(TvContractCompat.PreviewPrograms.CONTENT_URI, PreviewProgram.PROJECTION, PreviewProgram::fromCursor)
.groupBy { it.channelId }
val approved = approvals.approvedKeys() // 桌面自己的审批表:包名 + internalProviderId
channels.filter { ChannelKey(it) in approved }
.map { ch -> ChannelRow(ch, programs[ch.id].orEmpty().sortedByDescending { it.weight }) }
}
}
private fun <T> ContentResolver.readAll(uri: Uri, projection: Array<String>, map: (Cursor) -> T): List<T> =
query(uri, projection, null, null, null)?.use { c -> buildList { while (c.moveToNext()) add(map(c)) } }.orEmpty()
// API 23–25:旧版推荐通知
class RecommendationListener : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification) {
val n = sbn.notification
if (n.category != Notification.CATEGORY_RECOMMENDATION) return
legacyRepo.upsert(sbn.packageName, sbn.key, n.extras, RecommendationExtender(n), n.contentIntent)
}
override fun onNotificationRemoved(sbn: StatusBarNotification) = legacyRepo.remove(sbn.key)
}
系列应用:myBV、mydv、mytv-android
三个应用已经通过 TvProvider 发布频道,下表按 2026-11-02 的源码整理。接入原则:数据仍走上面的 A,这样它们在 Google TV 和原生 Android TV 桌面上照常工作;第三方桌面做不到的几件事,用一条系列内部的直连通道补上,不另起一套数据格式。
| 应用 | 频道 | 节目 | Watch Next | 发布条件 |
|---|---|---|---|---|
myBVcom.github.mytv.bv(迁移版 dev.aaa1115910.bv) | B 站推荐 mybv.recommend:默认频道,请求上屏稍后再看 mybv.toview:用户点"添加到电视桌面"后,用 ACTION_REQUEST_CHANNEL_BROWSABLE 请求确认 | TYPE_CLIP,16:9,bugvideo://video/{aid};稍后再看带播放进度 | 无 | API 26+、有 leanback 特性、有 TvProvider;稍后再看频道的 browsable 为 0 时不写节目 |
mydvcom.github.mytv.dv | 推荐 mydv_recommend:默认频道稍后再看 mydv_watch_later:设置页里请求确认,找不到处理者时只保留一个频道 | TYPE_CLIP,竖屏 2:3 与横屏 16:9 混排,mydv://douyin/video/{id} | 无 | API 26+、有 TvProvider |
mytv-androidcom.github.mytv.android 及渠道包 | 收藏:频道名为应用名,没有 internalProviderId,请求上屏 | TYPE_CHANNEL,海报是台标、未设比例,预览视频是直播流,mytv://android.recommendation?favorite_id=… | 1 条:最后观看的频道(CONTINUE);每 15 分钟删除后重写,互动时间写成当前时间 | 每次打开应用(MainActivity)和退出时发布,并排一个 15 分钟的周期 Job(未设持久化,重启后到下次打开前不刷新);另有 INITIALIZE_PROGRAMS 接收器,但未导出 |
在本桌面上要处理的问题
| 问题 | 原因 | 桌面的处理 | App 侧改动 |
|---|---|---|---|
| 默认频道上屏 | 第三方桌面不能写 browsable | 注册 CHANNEL_BROWSABLE_REQUESTED 接收器。系列应用的请求直接批准,其他应用进入"管理频道"待审 | 无 |
| 第二个频道(稍后再看) | App 启动 ACTION_REQUEST_CHANNEL_BROWSABLE 等待确认 | 桌面的确认页处理,确认后写审批表并返回 RESULT_OK | 无 |
| myBV 稍后再看确认后不再更新 | myBV 用 TvProvider 里的 browsable 判断频道是否可见;本桌面批准后它仍是 0,之后的同步都被跳过 | 直连 approve:把审批结果告诉 App | myBV:可见判断改为"browsable 为 1,或系列桌面已批准" |
| 在桌面移除的节目,下次同步又写回来 | 移除广播受保护,桌面发不出 | 直连 hide:传节目的 internalProviderId | myBV 按 aid 记墓碑(沿用现有存储);mytv-android 改为按 id 记录(现在按标题);mydv 新增墓碑 |
| mytv-android 装好但还没打开过时,没有频道 | INITIALIZE_PROGRAMS 接收器未导出。未导出的组件只有 root 和 system 进程能调用(ActivityManager.checkComponentPermission),第三方桌面、Shizuku(shell 身份)都不行,系统桌面通常也不行 | 不做特殊处理:用户打开一次 mytv-android 后频道就会出现 | 不改 |
| 没有 TvProvider 的盒子、API 23–25 | 三个应用都直接跳过发布 | 从直连读行数据 | 三个应用:用同一套 PreviewProgram / WatchNextProgram 构建结果,从自己的 Provider 返回 |
| mytv-android 的继续观看总排第一;审批键缺失 | 互动时间每 15 分钟被改成当前时间;频道没有 internalProviderId | 审批表缺 internalProviderId 时退回"包名 + 频道名" | 建议写真实的最后观看时间;频道设为 mytv.favorites |
- 台标卡:mytv-android 的收藏按 1:1 台标卡显示,底色取台标颜色。焦点预览只在 T1 设备上静音播放直播流,离开焦点立即释放。
- 混排比例:mydv 同一行里有 2:3 和 16:9,按下方字段表"行高统一、宽度按比例"处理,不裁成同一比例。
- 继续观看:myBV 和 mydv 目前没有 Watch Next。建议 myBV 把历史记录里没看完的视频写成
WATCH_NEXT_TYPE_CONTINUE,带进度和真实的最后观看时间,这样在本桌面和原生 Android TV 桌面的继续观看里都会出现;Google TV 上新的继续观看接入要走 Engage(见上文)。 - 小组件:mytv-android 的"继续观看"小组件和 mydv 的 Glance 小组件按 §12 的第三方流程添加,不需要单独处理。
系列直连协议 v1
| 项 | 约定 |
|---|---|
| 发现 | App 导出一个带 intent-filter 的 Provider,action 为 com.github.mytv.action.LAUNCHER_BRIDGE,authority 为 {包名}.launcherbridge。桌面用 queryIntentContentProviders() 查找,并在 <queries> 里声明同一 action |
| 身份校验 | 双向固定证书 SHA-256:桌面内置"包名 → 证书"名单;App 用 getCallingPackage() 取调用方,再比对桌面的证书。不要求两边用同一个签名,做法与 myBV 迁移桥接版的 MYBV_CERT_SHA256 相同。证书读取在 API 28+ 用 GET_SIGNING_CERTIFICATES,更低版本用 GET_SIGNATURES |
| 控制方法 | ContentResolver.call(uri, method, arg, extras):hello 返回协议版本;init 立即发布;approve 告知频道审批结果;hide 告知用户移除的节目 |
| 行数据 | 只在设备没有 TvProvider 或低于 API 26 时启用:/channels、/preview_programs、/watch_next_programs。列名与 TvContract 相同,桌面直接用 PreviewProgram.fromCursor() 等解析;以 internal_provider_id 为键;App 同步后调用 notifyChange() |
| 与 A 的关系 | 有 TvProvider 时数据只走 A,直连只做控制,同一节目不会出现两份 |
| 共用代码 | 协议、证书校验和列映射放进系列共用的 series-bridge 模块(minSdk 23),每个 App 只实现四个回调 |
// 桌面侧
object SeriesBridge {
const val ACTION = "com.github.mytv.action.LAUNCHER_BRIDGE"
private val TRUSTED = mapOf( // 包名 → 签名证书 SHA-256,构建时注入
"com.github.mytv.bv" to Certs.MYBV, "dev.aaa1115910.bv" to Certs.MYBV,
"com.github.mytv.dv" to Certs.MYDV, "com.github.mytv.android" to Certs.MYTV,
)
fun isSeries(ctx: Context, pkg: String) = TRUSTED[pkg]?.let { it == ctx.packageManager.certSha256(pkg) } == true
fun sources(ctx: Context): List<ProviderInfo> =
ctx.packageManager.queryIntentContentProviders(Intent(ACTION), 0)
.map { it.providerInfo }.filter { isSeries(ctx, it.packageName) }
fun call(ctx: Context, p: ProviderInfo, method: String, arg: String? = null): Bundle? =
runCatching { ctx.contentResolver.call(Uri.parse("content://${p.authority}"), method, arg, null) }.getOrNull()
}
// App 调用 requestChannelBrowsable() 时由系统发出;受保护广播,只有系统能发,所以导出也安全
// <receiver android:name=".ChannelRequestReceiver" android:exported="true">
// <intent-filter><action android:name="android.media.tv.action.CHANNEL_BROWSABLE_REQUESTED"/></intent-filter>
// </receiver>
class ChannelRequestReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val channelId = intent.getLongExtra(TvContract.EXTRA_CHANNEL_ID, -1L)
val pkg = intent.getStringExtra(TvContract.EXTRA_PACKAGE_NAME) ?: return
// 读频道行拿 internalProviderId;系列应用直接批准并调用 approve,其他应用记为待审
ApprovalWorker.enqueue(ctx, pkg, channelId, autoApprove = SeriesBridge.isSeries(ctx, pkg))
}
}
// App 侧:series-bridge 模块
abstract class LauncherBridgeProvider : ContentProvider() {
abstract fun onInit()
abstract fun onApprove(channelKey: String, approved: Boolean)
abstract fun onHide(programKey: String)
abstract fun rows(table: String): List<ContentValues> // 有 TvProvider 时返回空列表
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
LauncherTrust.check(context!!, callingPackage) // 证书不符抛 SecurityException
when (method) {
"hello" -> return bundleOf("version" to 1)
"init" -> onInit()
"approve" -> onApprove(arg!!, extras?.getBoolean("approved", true) ?: true)
"hide" -> onHide(arg!!)
}
return null
}
override fun query(uri: Uri, projection: Array<String>?, selection: String?, args: Array<String>?, sort: String?): Cursor {
LauncherTrust.check(context!!, callingPackage)
return rows(uri.lastPathSegment!!).toCursor(projection)
}
// insert / update / delete 一律抛 UnsupportedOperationException
}
各应用的改动
- myBV:接入 series-bridge。
init调syncRecommend(force)和syncToView(force);approve记入 TvChannelsStore,syncToView的可见判断加上它;hide按 aid 记墓碑;available()为 false 时由rows返回同一批 PreviewProgram。 - mydv:接入 series-bridge;新增墓碑;
isSupported()为 false 时由rows返回数据。 - mytv-android:接收器和周期 Job 保持现状;接入 series-bridge,
init调startJob();频道设 internalProviderId;继续观看写真实的最后观看时间;删除记录改为按 internalProviderId。 - 三个应用现有的 TvProvider 发布逻辑都不用改,在其他桌面上的行为不变。
桌面小组件
第三方应用的小组件(AppWidget)是核心功能,完整度对齐手机桌面:选择器、添加、移动、调整大小、重新配置、删除,并且可以用方向键进入小组件内部操作。电视有两处特殊:一是系统没有提供绑定授权的界面,二是 RemoteViews 本来是为触屏设计的。下面先列源码核实的事实,再分别给出处理方式。
设备现状(AOSP 源码核实)
| 事实 | 出处 | 对方案的影响 |
|---|---|---|
Android TV 参考配置没有声明 android.software.app_widgets,但把 config_enableAppWidgetService 设为 true,注释为"TV launcher customization requires AppWidgetService" | device/google/atv | 服务在运行,接口可用;Manifest 中声明 required="false" |
bindAppWidgetId 要求调用方持有 BIND_APPWIDGET(signature|privileged),或者在授权名单里 | AppWidgetServiceImpl | 普通安装的桌面默认无法绑定 |
授权名单由 setBindAppWidgetPermission 写入,需要 MODIFY_APPWIDGET_BIND_PERMISSIONS;shell 用户同时持有这两个权限 | AppWidgetServiceImpl;Shell 的 AndroidManifest;appwidget grantbind 命令 | 可以用 ADB,或用 Shizuku 以 shell 身份授权一次 |
授权写入系统的 appwidgets.xml(<b packageName>),重启后仍有效;加载时会丢弃已卸载包的授权 | AppWidgetServiceImpl 的 save / load | 只需授权一次;卸载重装后要重新授权 |
手机版 Settings 用 AllowBindAppWidgetActivity 处理 ACTION_APPWIDGET_BIND;AOSP TvSettings 的清单里没有这个处理者 | packages/apps/Settings、TvSettings | 不能指望系统确认对话框;运行时检测,存在就用 |
App 调用 requestPinAppWidget() 时,默认桌面收到 ACTION_CONFIRM_PIN_APPWIDGET;accept() 只把 appWidgetId 回传给 App,不负责绑定 | ShortcutRequestPinProcessor | 处理"添加到主屏幕"请求时,桌面仍要自己完成绑定 |
API 35 的 getWidgetPreview()(生成式预览)同样要求绑定权限 | AppWidgetServiceImpl | 授权之前,选择器只用 previewLayout / previewImage |
绑定授权:五条路径
每次绑定都先调用 bindAppWidgetIdIfAllowed(),失败时依次尝试后面的路径。授权一次之后,以后添加任何小组件都不再询问。原型里可以完整走一遍:「小组件」页 → 添加小组件 → 选一个第三方小组件。
| 顺序 | 路径 | 前提 | 用户操作 |
|---|---|---|---|
| 1 | 直接绑定成功 | 已在授权名单,或 oem 变体持有 BIND_APPWIDGET | 无 |
| 2 | 系统确认界面 ACTION_APPWIDGET_BIND | resolveActivity() 不为空(部分厂商系统有) | 确认一次,可勾选"始终允许" |
| 3 | Shizuku 一键授权 | 设备装有 Shizuku 并已启动,用户允许本应用使用 | 点"授权";桌面以 shell 身份调用 setBindAppWidgetPermission(自身包名, user, true) |
| 4 | ADB 命令 | 开发者选项中打开网络调试 | 在电脑上执行一次 adb shell appwidget grantbind --package <桌面包名> --user current;手机整理页(§14)也显示这条命令,方便复制 |
| 5 | oem 变体 | 系统签名或预置 priv-app | 无 |
首次启动时,如果检测到路径 1 不可用、但有任何第三方 provider,引导页里出现"启用第三方小组件"一步,可以跳过。没有授权时,选择器照常列出第三方小组件,选中后才进入授权步骤,不预先隐藏。
添加流程
getInstalledProviders() 按 App 分组;预览依次用 previewLayout(API 31+)、previewImage、图标选择器allocateAppWidgetId()AppWidgetHoststartAppWidgetConfigureActivityForResult()取消则删除class WidgetBinder(private val ctx: Context, private val host: AppWidgetHost, private val awm: AppWidgetManager) {
sealed interface Bind { data class Ok(val id: Int) : Bind; data class NeedsGrant(val id: Int) : Bind }
fun bind(info: AppWidgetProviderInfo, options: Bundle): Bind {
val id = host.allocateAppWidgetId()
return if (awm.bindAppWidgetIdIfAllowed(id, info.profile, info.provider, options)) Bind.Ok(id) else Bind.NeedsGrant(id)
}
// 路径 2:只有设备真的有处理者时才用
fun systemBindIntent(id: Int, info: AppWidgetProviderInfo): Intent? =
Intent(AppWidgetManager.ACTION_APPWIDGET_BIND)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER, info.provider)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER_PROFILE, info.profile)
.takeIf { it.resolveActivity(ctx.packageManager) != null }
}
// 路径 3:Shizuku 以 shell 身份授权一次(IAppWidgetService 为隐藏接口,需要隐藏 API 存根,做法与 Shizuku 官方示例相同)
fun grantBindViaShizuku(ctx: Context) {
val binder = ShizukuBinderWrapper(SystemServiceHelper.getSystemService(Context.APPWIDGET_SERVICE))
IAppWidgetService.Stub.asInterface(binder)
.setBindAppWidgetPermission(ctx.packageName, Process.myUid() / 100_000 /* userId */, true)
}
// API 26+:App 发起"添加到主屏幕"。Manifest:<action android:name="android.content.pm.action.CONFIRM_PIN_APPWIDGET"/>
class PinWidgetActivity : ComponentActivity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
val req = getSystemService(LauncherApps::class.java).getPinItemRequest(intent)
if (req == null || !req.isValid || req.requestType != LauncherApps.PinItemRequest.REQUEST_TYPE_APPWIDGET) return finish()
// 显示确认页 → 走同一个 WidgetBinder;绑定成功后:
// req.accept(bundleOf(AppWidgetManager.EXTRA_APPWIDGET_ID to id)) —— accept 不负责绑定
}
}
网格与尺寸
| 项 | 规格 |
|---|---|
| 小组件页画布 | 8 列 × 3 行,单元 100 × 112dp,间距 8dp;放不下时横向分页 |
| 默认格数 | API 31+ 优先用 targetCellWidth/Height;否则按 minWidth/minHeight 换算 |
| 可调范围 | 受 resizeMode、minResizeWidth/Height,以及 API 31 的 maxResizeWidth/Height 约束 |
| 下发尺寸 | API 31+ 用 updateAppWidgetSize(Bundle, List<SizeF>);更低版本用带 min/max 宽高的旧重载 |
| 主页速览行 | 用户挑选最多 4 个,按原尺寸显示(高不超过 2 行);是实时的 AppWidgetHostView,放在普通 Row 里,不放进 Lazy 列表,避免回收后反复重新加载 |
遥控器操作
外框层
- 焦点停在小组件外框上:1.1 倍、外环、光晕,与内容卡片一致。
- OK:进入内部交互。
- 长按 OK / MENU:菜单,含移动、调整大小、重新配置(provider 声明 reconfigurable 时)、加入主页速览、删除。
- 移动 / 调整大小:方向键移动虚线占位框,OK 确认,BACK 取消;位置冲突时,其他小组件按网格顺序让位。
内部层
- API 26+ 的 View 默认
focusable="auto":设置了点击 PendingIntent 的 View 会自动变成可聚焦。 - API 23–25:每次 RemoteViews 应用后,宿主遍历视图树,把可点击的 View 设为可聚焦。
- 焦点框由宿主统一绘制(通过
OnGlobalFocusChangeListener),不依赖小组件自带的 selector。 - 没有可聚焦 View 时,OK 直接 performClick 第一个可点击的 View。ListView、GridView、StackView 内部用方向键滚动。
- BACK 回到外框层。
宿主规则
- 生命周期:
startListening()放在 onStart,stopListening()放在 onStop。启动时用getAppWidgetIds()(API 26+)与 Room 对账,删除孤立的 ID。 - provider 变化:App 更新、卸载或停用时,刷新选择器,失效的实例显示占位卡,提供"移除"。
- 崩溃隔离:布局加载失败或 RemoteViews 异常时,只替换这一个小组件为占位卡,提供"重新加载"和"移除"。
- 玻璃位置:Frame 玻璃只画在 AppWidgetHostView 的后面和四周,不盖在上面。
- 内置小组件:时钟与天气(见下文"内置时钟与天气")、正在播放(MediaSession,需要通知使用权)、日程、存储与网络。它们是 Compose 实现,作为第三方之外的补充,完整套用全局与局部主题。
内置时钟与天气
顶栏时钟、环境模式大时钟和"时钟与天气"小组件共用下列数据与规则。
| 项 | 规则 |
|---|---|
| 12 / 24 小时制 | 默认 24 小时制;只有系统明确设为 12 小时制(Settings.System.TIME_12_24 为 "12")时才用 12 小时制。不直接用 DateFormat.is24HourFormat():该设置为空时它按区域默认值判断,zh_CN 会得到 12 小时制。 |
| 日期与农历 | 公历月日、星期,后接农历月日(形如"九月十二")。农历用内置的 1901–2100 年数据表计算,各 API 级别结果一致(android.icu.util.ChineseCalendar 从 API 24 才有,只用于测试比对)。系统语言为中文时默认显示农历,其他语言默认不显示,设置中可开关。 |
| 天气数据 | Open-Meteo 预报接口,无需密钥。显示当前温度、天气图标和当日最高 / 最低温。最多每小时刷新一次,结果缓存 3 小时;超过 3 小时没有新数据时隐藏天气,只留时钟。请求失败按 1 / 5 / 15 分钟退避,不提示。 |
| 城市 | 优先用手机整理页设置的城市(中文名先查内置城市表,再用 Open-Meteo 地名检索)。没有设置时按 IP 推测城市,最多每天一次;推测结果显示在桌面设置的天气项("当前城市:{城市}(按网络位置)"),可以改。不申请定位权限。 |
| 请求内容 | 只带经纬度(两位小数)与语言,不带设备标识。 |
| 许可 | Open-Meteo 免费接口限非商业使用,数据按 CC BY 4.0 署名;关于页列出 Open-Meteo 与城市表来源 GeoNames。standard 按非商业发行使用免费接口;oem 或任何商业发行改用付费计划或其他供应商(§20)。IP 推测所用服务的条款在 M0 核对。 |
让第三方小组件跟随桌面配色(API 31+)
AppWidgetHostView.setColorResources(SparseIntArray) 可以覆盖小组件引用的 system_accent1/2/3_* 和 system_neutral1/2_* 颜色资源。官方文档说明,调用它会让小组件完整重新加载,所以只在两种情况下调用:全局主题稳定 3 秒以上,或用户修改了壁纸 / 主题设置。焦点移动不触发。原型里的第三方小组件按同样的规则换色:主题稳定 3 秒后才变,并带一次"重新加载"的闪动。
// 色调板 → 系统颜色资源;每组 13 个,0 对应色调 100,1000 对应色调 0
private val TONES = intArrayOf(100, 99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0)
@RequiresApi(31)
fun LauncherScheme.toWidgetColors() = SparseIntArray().apply {
listOf(
WidgetIds.accent1 to primaryPalette, WidgetIds.accent2 to secondaryPalette, WidgetIds.accent3 to tertiaryPalette,
WidgetIds.neutral1 to neutralPalette, WidgetIds.neutral2 to neutralVariantPalette,
).forEach { (ids, palette) -> ids.forEachIndexed { i, id -> put(id, palette.tone(TONES[i])) } }
}
// WidgetIds.accent1 = intArrayOf(android.R.color.system_accent1_0, system_accent1_10, system_accent1_50, system_accent1_100 … system_accent1_1000)
themeController.settled.debounce(3_000).collect { scheme ->
if (Build.VERSION.SDK_INT >= 31) hostViews.forEach { it.setColorResources(scheme.toWidgetColors()) }
}
API 34 新增的 system_primary_dark 等语义颜色资源能否同样被覆盖,待 M0 验证。
环境模式与视频取色
延续 aerial-tv 的做法:主页空闲后,航拍视频在磁贴后方播放。v3 增加了两件事:返回界面时抓帧,让玻璃效果在视频背景上也能用;播放期间按关键帧取色。
| 问题 | 处理 |
|---|---|
| 为什么不用 TextureView | Media3 文档说明 TextureView 不支持 DRM 安全输出,在电视上也拿不到 HDR 和全显示分辨率。航拍片源本身是 4K / HDR,所以继续用 SurfaceView。 |
| PixelCopy | PixelCopy.request(SurfaceView, Bitmap, …) 从 API 24 开始可用,带 Rect 的重载从 API 26 开始。目标 Bitmap 小时直接得到缩小后的帧。受保护内容返回 ERROR_SOURCE_INVALID,此时使用片单里预先准备的海报帧和 keyColor。API 23 只走这条预置路径。部分机型上 PixelCopy 得到全黑或耗时过长,处理见下文"真机抓帧"。 |
| 解码能力 | 启动时用 MediaCodecList 检查 HEVC、杜比视界和 4K 解码;API 24+ 再用 Display.getHdrCapabilities() 检查 HDR。低档设备固定 1080p SDR。 |
| 缓存 | Media3 SimpleCache,LRU;上限与预取见下文"画质、缓存与失败处理"。 |
| 时钟 | 环境模式大时钟(72sp)下方一行显示公历日期、星期、农历与天气;24 小时制与数据来源见 §12"内置时钟与天气"。 |
| 烧屏 | 时钟每分钟随机偏移最多 24dp;环境模式持续 30 分钟后降低亮度遮罩,并交给系统屏保。 |
| 屏保 | 提供 DreamService,复用同一套渲染。用户需要在系统屏保设置中选择;部分 Google TV 版本是否列出第三方屏保,需实机确认;没有列出时用桌面内屏保(见下文"屏保兜底")。 |
| 版权 | 只用自有、已授权或许可明确的片源;片单附带许可信息与下架机制(§04)。 |
片源
| 片源 | 说明 |
|---|---|
| 航拍片单 | 自有或已授权的影片(AmbientClip),每段带许可信息、预置关键帧图和 keyColor |
| 自己的视频 | 从手机整理页上传(§14),存在应用私有目录;不出声、铺满画面;全部轮播或单段循环 |
| 信号源画面 | 固定的 HDMI 输入的实时画面(§14"HDMI 实时画面作壁纸") |
| 静态壁纸 | 内置程序绘制图、必应每日一图、手机上传的照片(可每天轮换) |
自己的视频
- 上传:手机页把文件作为原始请求体发送(不用 multipart),服务端边收边写临时文件,不整段读入内存。写完后用
MediaExtractor确认至少有一条视频轨,且MediaCodecList有对应的解码器;不满足时删除文件,手机页显示"无法播放这个视频"。 - 限额:单个文件 ≤ 2 GB;全部视频合计 ≤(已存视频 + 当前可用空间)的一半,且上传后可用空间不少于 1 GB;最多 64 段。先按 Content-Length 预检,超限直接拒绝;传输中超限立即中止并删除临时文件。
- 播放:与航拍共用环境模式的播放器和规则。没有预置关键帧,返回界面时的静态帧按"真机抓帧"的回退路径取得;keyColor 在上传完成后取一帧算好,存入
UserClip。
必应每日一图
- 接口:
https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=8&mkt=zh-CN,每天最多请求一次。只使用wp为 true 的图片(必应标注可用作壁纸)。 - 分辨率:界面分辨率高于 1080p 时下载
urlbase加_UHD.jpg的版本,否则用url给出的 1920×1080 版本。 - 保留:本机只保留最近 8 张;"最新"选项始终跟随最新一张。
- 署名:壁纸设置页里,当前图片下方显示必应返回的
copyright原文。 - 只用于显示:图片存在应用私有目录,不提供导出、分享或从手机页下载,也不用作其他界面的素材。
- 开关与兜底:桌面设置可关闭此来源,关闭时删除已下载的图片;请求失败、来源关闭或没有可用图片时回到内置壁纸。风险见 §20。
真机抓帧
aerial-tv(LiveWallpaper.kt)在真机上遇到两类问题:小米 MStar 机型上 PixelCopy 返回成功但画面全黑;TCL MT5879 上能抓到,但 4K 帧要 0.6–1.7 秒,期间主页卡住。本方案的处理:
- 不在主线程等待:PixelCopy 的回调放在专用 HandlerThread;返回界面的动画不等结果,先显示上一张静态帧或预置关键帧,抓到后交叉淡入。
- 150 ms 预算:任何一次成功的抓帧耗时超过 150 ms,本机此后不再使用 PixelCopy。
- 全黑检测:结果按 16×9 网格采样,最大亮度低于 4% 视为全黑。当前片段的 keyColor 不是近黑色时,连续两次全黑即判定本机不可用。
- 按固件记住:判定结果以
Build.FINGERPRINT为键存入 DataStore,固件升级后重新探测;devices.json 可以预置结论(§16)。 - 回退:航拍片单为每段影片预先提供关键帧图(按时间索引)和 keyColor,按播放位置取最近的一张。没有预置帧的片源(自己的视频)用
MediaMetadataRetriever.getFrameAtTime(t, OPTION_PREVIOUS_SYNC)从本地文件取;航拍只从 1080p H.264 文件取,不碰 4K HEVC 与杜比视界文件。 - 解析器卡死:部分固件的 MPEG4 解析器会在 MediaMetadataRetriever 里卡死(aerial-tv 在 TCL 上遇到)。取帧放在独立线程,10 秒超时;超时一次后,本次开机内不再从文件取帧,只用预置帧或壁纸。
解码器交接
电视通常只有一个 4K / HDR 解码实例。桌面的播放器占着它时,用户打开的应用可能起播失败。
- 主动离开(打开应用或信号源,或在播放器暂停时启动屏保):先抓帧(可用时),再释放暂停中的播放器(解码器与 Surface 一起释放),最后发出启动 Intent。环境模式正在播放时进入本应用屏保不在此列,见"从环境模式到屏保"。
- 被动离开(其他应用或系统界面来到前台、待机):播放器停在当前帧,最多再占用解码器 2.5 秒用于取得静态帧,到时无论成败都释放。
- 回来:从记住的片段与位置重新起播。
- 离开 HDMI 后延迟:devices.json 标记的机型(如 Mi TV 4,aerial-tv
FilmAfterInput.kt记录过整机重启)从信号源回到主页后,等 2.5 秒再打开解码器,期间显示静态帧或壁纸。
画质、缓存与失败处理
| 项 | 规则 |
|---|---|
| 画质选项 | 自动 / 1080p H.264 / 4K HEVC / 4K 杜比视界。"自动"按解码能力、HDR 能力和设备档位(§17)选可播放的最高一档。 |
| 运行中降级 | 解码器初始化或解码失败(Media3 错误码 4001–4005)时,在本进程内降一档重新起播,不改用户设置;再失败继续降,最低到 1080p H.264。 |
| 缓存上限 | 可用空间的 1/4,限定在 64 MB–3 GB;LRU 淘汰;边播边写入。 |
| 预取 | 定时屏保开始前 8 秒,预取下一段开头 6 MB;环境模式中下一段开始前同样预取。只在非计费网络上进行。 |
| 失败阶梯 | 连接 5 秒、读取 8 秒超时,重试一次;起播后 8 秒内没有画面则跳到下一段;连续 3 段失败后改用照片或壁纸,下次进入环境模式时再试影片。 |
从环境模式到屏保
- 同一个播放器:环境模式正在播放时进入本应用的屏保(桌面内屏保或本应用的 DreamService,同一进程),把播放器的输出 Surface 切到屏保窗口,片段、位置和画质不变,不黑屏、不重新缓冲。只有用户为屏保单独选了更高画质(如 4K 杜比视界)时才重新起播,切换时可能黑屏一次。
- 跨重启续播:当前片段 ID 与播放位置(毫秒)每 10 秒及离开时写入 DataStore;进程重启或开机后从这里继续。
- 小集合轮换:航拍片单在本机只轮换 3 段,每天换入一段新的、移出最旧的一段,提高缓存命中;屏保中左右键切换上一段 / 下一段。
屏保兜底
- 桌面内屏保:本应用的 DreamService 不是当前系统屏保(
Settings.Secure的screensaver_components不含本组件,或读取不到),或机型在 devices.json 中标记为不列出第三方屏保时启用。主页空闲达到设定时长(默认 10 分钟)后,桌面在自己的窗口里全屏播放;只在桌面位于前台时生效。 - 不妨碍待机:桌面内屏保运行时设置
FLAG_KEEP_SCREEN_ON,累计达到系统关屏时长(Settings.System.SCREEN_OFF_TIMEOUT)后清除,交回系统按原设置待机。 - 立即启动:快速设置的"屏保"项提供"立即启动",直接进入本应用屏保(§14)。
- 内容:航拍片单、自己的视频、照片(含必应每日一图)、时钟,与 DreamService 共用一套渲染;任意键退出,回到进入前的页面与焦点。
快速设置 · HDMI · 手机整理
普通应用权限能直接完成的才在面板里直接执行,其他操作跳转到系统设置;oem 变体可以直接执行。
| 项 | standard 变体 | oem 变体 |
|---|---|---|
| Wi-Fi / 蓝牙 | 显示状态;OK 打开对应的系统设置页(API 29 起 setWifiEnabled 对普通应用无效) | 直接开关 |
| 音量 | AudioManager.adjustStreamVolume(STREAM_MUSIC, …),粗轨滑块 | 同左 |
| 亮度 / 画质 | 不提供(需要 WRITE_SETTINGS,电视设置里常常没有授权入口) | 厂商接口 |
| 已隐藏的应用 | 快速设置末尾的"桌面设置"里查看和恢复;主页和应用页不留入口 | 同左 |
| 环境模式、勿扰、取色来源、玻璃档位、配色变体 | 桌面自身设置,直接生效(原型面板中可操作) | 同左 |
| 信号源 | TvInputManager.getTvInputList() 列出输入源;用 TvContract.buildChannelUriForPassthroughInput(inputId) 发 ACTION_VIEW;没有 Activity 处理时隐藏此项 | 直接切换 |
| 屏保 | "立即启动":进入本应用屏保(§13"屏保兜底") | 同左 |
| 家长 | 儿童 / 长辈模式开启时出现;输入 PIN 后进入家长菜单(见下文) | 同左 |
- HDMI 固定:信号源可以固定到"你的应用"行,显示为圆形图标,图标和种子色按设备类型预设(游戏机、机顶盒、回音壁、电脑)。
- 手机整理:只在用户打开时启动局域网服务。技术选型参考 aerial-tv(NanoHTTPD + ZXing 二维码;它用固定端口 8790 和持久令牌,应用运行期间一直监听)。本方案用随机端口,二维码里带一次性令牌,会话 10 分钟后失效;只监听局域网地址;上传类型与限额见下文"手机整理页";所有写操作在电视上弹确认。手机页面同时显示小组件授权用的
appwidget grantbind命令,便于复制到电脑执行(§12)。
信号源补充
- 信号源键:
KEYCODE_TV_INPUT打开快速设置的信号源页(§10)。devices.json 标记"固件自带信号源面板"的机型不消费这个键,交给固件,避免两个面板同时出现(aerial-tv 在 TCL MT5879 上遇到)。 - 固件信号源界面:
getTvInputList()为空,或切换用的 ACTION_VIEW 没有处理者时,信号源项改为打开固件自己的信号源界面(组件名记在 devices.json);仍没有时隐藏此项。海信、康佳等不经过 TvInputManager 的私有接口只在 oem 变体实现。 - 开机 / 唤醒目标:桌面设置的"开机后打开"可选主页(默认)、一个信号源或一个应用。每次开机执行一次,每次从待机唤醒执行一次,同一次亮屏期间不再执行。开机判断用
Settings.Global.BOOT_COUNT(API 24+),API 23 比较开机时刻(System.currentTimeMillis() - SystemClock.elapsedRealtime())。唤醒时只在桌面位于前台时执行;HDMI-CEC 已把电视切到某个输入(前台已不是桌面,或当前输入就是目标)时跳过。开机后按 devices.json 的延迟等输入服务就绪再切换。
HDMI 实时画面作壁纸
- 来源:壁纸可选一个已固定的信号源。只列出 TvInputManager 列出的输入(inputId 为 TvInputService 组件形式,含 "/"),不包括固件私有信号源。
- 播放:主页背景层放一个
TvView,附加到窗口 0.8 秒后调用tune(inputId, TvContract.buildChannelUriForPassthroughInput(inputId));setStreamVolume(0f)静音。 - 弱信号:收到
VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL时在 3 / 5 / 10 / 15 / 20 / 30 秒后重试,仍失败则显示内置深色底图,直到下次回到主页。 - 界面:主页静止后与环境模式一样淡出界面框架(§06"信号源画面"状态)。直通画面无法抓帧,返回界面时背景换成内置深色底图做 T2 玻璃,取色用该信号源按设备类型预设的种子色。
- 交接:打开应用、信号源或屏保前先
TvView.reset()释放会话;离开 HDMI 后的解码器延迟同 §13"解码器交接"。
儿童模式、长辈模式与家长 PIN
两种受限模式在桌面设置里开启,共用一个家长 PIN。§07 中锁定固定种子色的就是这两种模式。
| 项 | 儿童模式 | 长辈模式 |
|---|---|---|
| 主页 | 只显示允许的应用与信号源;不显示 Hero、频道行和小组件 | 只显示允许的应用与信号源,大磁贴每排 4 个,名称全部常显;不显示 Hero、频道行和小组件 |
| 时长 | 工作日、周末分别设置每日分钟数;允许时段(例如 08:00–20:30);连续观看多久后休息多久 | 不限 |
| 提醒 | 剩余 5 分钟、1 分钟时各一次 | — |
| 锁屏 | 额度用完、不在允许时段或到了休息时间时显示:休息剩余时间的倒计时,或下一次可用的时间。家长输入 PIN 后可选"+15 分钟""+30 分钟""今天不限" | — |
| 快速设置 | 只保留音量、允许的信号源和"家长" | 同左 |
| 配色与焦点框 | 固定种子色 | 固定种子色;强制高对比焦点框(§09) |
- PIN:4 位数字,或 4 次方向键(没有数字键的遥控器也能输入)。保存随机 16 字节盐与 SHA-256 哈希,不存明文。连续输错 5 次锁定 60 秒。
- 解锁:输入正确后 10 分钟内可修改设置、暂停模式、打开不在允许列表里的应用;按 HOME、进入屏保或关屏时立即重新上锁。
- 暂停与恢复:从"家长"菜单退出只是暂停,进入屏保、关屏或重启后自动恢复。关闭模式要在桌面设置里输入 PIN 后关掉开关。
- 忘记 PIN:界面上没有重置入口。只能用 ADB:
adb shell am broadcast -n <pkg>/.config.ConfigReceiver --ez clear_pin true(接收器要求发送方持有 WRITE_SECURE_SETTINGS,只有 shell 和系统满足),或adb shell pm clear <pkg>(同时清空布局)。系统设置默认不在允许列表里,从电视上无法清除桌面数据。
standard 变体不用无障碍服务,各项的执行手段与前提如下:
| 能力 | 手段 | 前提 |
|---|---|---|
| 主页只显示允许项 | 桌面自身 | 无 |
| 计时 | 屏幕亮着且桌面不在前台即计为观看。进程内每 10 秒累计一次,两次之间超过 20 秒视为待机、不计入;关屏或回到主页达到设定的休息时长,连续观看计数清零。进程被回收后按记录的时间戳补算 | 无(只知道"在看",不知道在看哪个应用) |
| 到时把锁屏拉到前台 | 桌面从后台启动自己的锁屏 Activity | API 23–28 没有后台启动限制。API 29+ 依赖 AOSP 对默认桌面的豁免(BackgroundActivityStartController 的 isHomeApp),各版本与厂商固件是否一致在 M0 验证;不成立时用 ADB 授予悬浮窗权限:adb shell appops set <pkg> SYSTEM_ALERT_WINDOW allow(持有该权限的应用同样豁免) |
| 锁屏期间离开即回来 | 锁屏状态下,桌面 onStop 而屏幕仍亮时立即重新拉起锁屏 | 同上 |
| 拦住不允许的应用(例如从允许的应用里跳转出去) | 每 2 秒用 UsageStatsManager.queryEvents() 查看前台应用,不在允许列表时拉回桌面 | "使用情况访问"权限。系统设置有入口时从桌面设置跳过去;电视上常没有入口,用 adb shell appops set <pkg> GET_USAGE_STATS allow。未授权时做不到这一项 |
| 剩余 5 / 1 分钟提醒 | 纯文字 Toast(API 30+ 后台只允许纯文字 Toast);已授予悬浮窗权限时改用桌面自绘的提示条 | 无 |
- 状态展示:桌面设置的儿童模式页列出"使用情况访问:已授予 / 未授予"和"悬浮窗:已授予 / 未授予",以及对应的 ADB 命令。
- 做不到时:某台设备上锁屏无法从后台拉起时,额度用完后只能在回到主页时锁定;没有使用情况访问权限时,从允许的应用跳转到的其他应用拦不住。需要系统级强制时用 oem 变体:预装为特权应用,直接持有 PACKAGE_USAGE_STATS 与后台启动权限。
设为默认桌面与 HOME 兼容
- 清单:主 Activity 的 intent-filter 声明
android.intent.action.MAIN与android.intent.category.HOME、android.intent.category.DEFAULT、android.intent.category.LAUNCHER、android.intent.category.COMMON_HOME;另一个 filter 声明android.intent.category.HOME.CC与 DEFAULT;再加<meta-data android:name="com.tcl.app.type" android:value="com.tcl.ui" />。后三项是部分国内固件识别桌面用的非标准值,字符串取自 aerial-tvapp/src/standard/AndroidManifest.xml,效果在 M0 的测试固件上核实。 - Activity 属性:
launchMode="singleTask"、stateNotNeeded="true"、clearTaskOnLaunch="true"、excludeFromRecents="true"、resumeWhilePausing="true"、screenOrientation="landscape"。 - "默认桌面"页:首次启动引导的第一步,桌面设置里也有同一页。页面显示"已是默认桌面"或"当前默认桌面:{应用名}"(用
resolveActivity(HOME, MATCH_DEFAULT_ONLY)判断)。"设为默认桌面"按钮依次尝试:API 29+RoleManager.createRequestRoleIntent(ROLE_HOME);Settings.ACTION_HOME_SETTINGS;发 HOME Intent 让系统弹出选择器。都没有结果时,页面列出 ADB 命令adb shell cmd package set-home-activity <pkg>/.MainActivity,API 29+ 另有adb shell cmd role add-role-holder android.app.role.HOME <pkg>(两条命令在 M0 核对)。 - 失效检测:桌面每次回到前台检查一次是否仍是默认桌面;不是时只在桌面设置的"默认桌面"项显示状态,不弹窗。
手机整理页
| 功能 | 说明 |
|---|---|
| 改名 | 应用、信号源、文件夹的显示名称;只影响桌面显示 |
| 自定义图标 | 为应用上传一张图片,裁成 16:9 用于应用页网格,按包名保存;删除时只删用户上传的文件 |
| 壁纸照片 | JPEG / PNG / WebP,单张 ≤ 20 MB |
| 自己的视频 | 流式上传;类型检查与限额见 §13"自己的视频" |
| 天气城市 | 输入城市名检索并选定;设置后不再用 IP 推测(§12) |
| 诊断 | 下载诊断报告(见下文) |
| 小组件授权 | 显示 appwidget grantbind 命令(§12) |
- 二维码地址:选择局域网 IPv4 地址时跳过 VPN 网络(
NetworkCapabilities.TRANSPORT_VPN)、tun 类接口和 198.18.0.0/15(代理软件常用的虚拟网段);有多个候选时以太网优先,其次 Wi-Fi。没有可用地址时显示"未连接局域网",不启动服务。
诊断导出
- 内容:DiagLog 环形缓冲(最近 200 行,同时写入应用私有文件);帧时间统计(每个页面最近 600 帧的 P50 / P90 / P99;API 24+ 用 FrameMetrics,API 23 用 Choreographer 估算);设备与固件信息(厂商、型号、SoC、API 级别、
Build.FINGERPRINT、显示模式与刷新率、解码能力、设备档位、命中的 devices.json 条目与 ADB 覆盖项、PixelCopy 判定);桌面版本、变体、是否为默认桌面、各项授权状态。 - 脱敏:IP 地址只留前两段,MAC 只留前 3 字节,令牌与 PIN 相关字段整体替换。不收集账户、Wi-Fi 名称、序列号、城市和已安装应用列表。
- 导出:只通过手机整理页下载纯文本报告;桌面不向任何服务器上传。
技术栈与版本锁定
minSdk 23 是硬约束,下表的每个版本都按它筛过。数据截至 2026-11-02。
| 组件 | 版本 | minSdk | 结论 |
|---|---|---|---|
| Compose BOM | 2026.09.00(ui / foundation 1.12.1) | 23 | 使用;material3 单独指定版本覆盖 BOM 里的 1.4.0 |
| Compose ui / foundation | 1.13.0-alpha01 起 | 24 | 不可用,锁定 1.12.x |
| material3 | 1.5.0-alpha25 | 23 | 使用。依赖 Compose 1.12.0-beta01 与 graphics-shapes 1.0.1,Gradle 会解析到 BOM 的 1.12.1,同一条版本线 |
| material3 | 1.4.0 | 23 | BOM 默认版本,没有 Expressive API,被上一行覆盖 |
| material3 | 1.5.0-alpha26 至 beta02 | 24 | 不可用 |
| androidx.graphics:graphics-shapes | 1.0.1 | M0 核对 | 由 material3 传递引入:Morph、RoundedPolygon |
| Haze | 2.0.1:haze + haze-blur | 23 | 使用;不引入 haze-glass(实验性) |
| MaterialKolor | 6.0.0:material-color-utilities | M0 核对 | 只用 MCU 移植模块。material3 适配模块按 alpha22 编译;在 alpha25 下它的 Expressive 部分是否二进制兼容未验证,且主题过渡需要自己的 ThemeController,所以不用 |
| Media3 | 1.9 及以上 | 23 | 使用:exoplayer、ui-compose |
| androidx.tvprovider | 最新稳定版 | — | 使用 |
| androidx.recommendation | 1.0.0 | — | 使用,仅 API 23–25 路径 |
| tv-material / tv-foundation | 1.1.0 / 1.0.0 | — | 不使用(§10) |
| Coil | 3.x | 21 | 使用;取色用的缩略图关闭硬件位图 |
| Kotlin | 2.4.20 | — | Haze 2.0.1 的构建版本,项目不低于此版本 |
| Shizuku API | 13.x(api + provider) | — | 可选,仅用于小组件绑定授权(§12 路径 3);运行时检测,不装也能用 |
// app/build.gradle.kts(节选)
android { defaultConfig { minSdk = 23; targetSdk = 36 } }
dependencies {
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.material3:material3:1.5.0-alpha25") // 最后一个 minSdk 23 的 1.5.0;不要升级
implementation("dev.chrisbanes.haze:haze:2.0.1")
implementation("dev.chrisbanes.haze:haze-blur:2.0.1")
implementation("com.materialkolor:material-color-utilities:6.0.0")
implementation(libs.media3.exoplayer); implementation(libs.media3.ui.compose) // ≥ 1.9
implementation(libs.tvprovider); implementation(libs.recommendation); implementation(libs.coil.compose)
implementation(libs.shizuku.api); implementation(libs.shizuku.provider) // 可选:小组件绑定授权
}
// 防止被依赖升级悄悄抬到 alpha26+(minSdk 24):
configurations.all { resolutionStrategy.force("androidx.compose.material3:material3:1.5.0-alpha25") }
// M0 必做:./gradlew :app:dependencies 确认没有被传递依赖抬高 minSdk(manifest merger 也会报错)
主题与动效(基于 material3 1.5.0-alpha25)
@Composable
fun LauncherTheme(theme: ThemeController, reducedMotion: Boolean, content: @Composable () -> Unit) {
MaterialExpressiveTheme(
colorScheme = theme.colorSchemeSnapshot(), // 必须显式传暗色方案:默认是 expressiveLightColorScheme()
motionScheme = if (reducedMotion) ReducedMotionScheme else MotionScheme.expressive(),
typography = LauncherTypography, // 含 *Emphasized;CJK 映射到 700 / 800
shapes = LauncherShapes, // 卡片 16、焦点 24、面板 28–32、Hero 48
content = content,
)
}
// 颜色过渡期间每帧变化的部分(环境遮罩、玻璃 tint、光环)在绘制阶段读取 theme.colors(),
// 传给 MaterialExpressiveTheme 的方案只在过渡结束时更新一次,避免整树重组。
// 组件里统一从主题取动效
val motion = MaterialTheme.motionScheme
val scale by animateFloatAsState(if (focused) 1.1f else 1f, motion.fastSpatialSpec()) // 0.6 / 800
val rowScroll = MotionScheme.standard().defaultSpatialSpec<Float>() // 0.9 / 700:长距离不晃
val ambient = spring<Float>(dampingRatio = 1f, stiffness = 200f) // 环境色过渡,本方案自定
// 减弱动效:spatial 改为 snap,effects 保留
object ReducedMotionScheme : MotionScheme by MotionScheme.standard() {
override fun <T> defaultSpatialSpec(): FiniteAnimationSpec<T> = snap()
override fun <T> fastSpatialSpec(): FiniteAnimationSpec<T> = snap()
override fun <T> slowSpatialSpec(): FiniteAnimationSpec<T> = snap()
}
// 图标焦点形变:圆 → Cookie9Sided(MaterialShapes 已归一化到 0..1)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
val IconMorph = Morph(MaterialShapes.Circle, MaterialShapes.Cookie9Sided)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
class MorphShape(private val morph: Morph, private val progress: Float) : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val path = morph.toPath(progress) // material3 提供的 Morph.toPath
path.transform(Matrix().apply { scale(size.width, size.height) })
return Outline.Generic(path)
}
}
// Modifier.graphicsLayer { shape = MorphShape(IconMorph, focusProgress.value); clip = true }
// Hero 按钮组:M3 ButtonGroup 只在按下时变宽,电视改为焦点驱动
@Composable
fun TvButtonGroup(buttons: List<TvButtonSpec>) = Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
buttons.forEach { spec ->
var focused by remember { mutableStateOf(false) }
val extra by animateDpAsState(if (focused) 20.dp else 0.dp, MaterialTheme.motionScheme.defaultSpatialSpec())
val corner by animateDpAsState(if (focused) 14.dp else 23.dp, MaterialTheme.motionScheme.fastSpatialSpec())
TvButton(spec, Modifier.onFocusChanged { focused = it.isFocused }.padding(horizontal = extra / 2), RoundedCornerShape(corner))
}
}
架构、模块与数据
单 Activity + Compose,UDF。主题状态单独一条数据流,在绘制阶段读取:主题过渡的约 600ms 里不触发列表重组。
模块
app standard / oem 两个 flavor
core:designsystem MaterialExpressiveTheme 封装、形状、字体
core:dynamiccolor 取色、配色、ThemeController、SeedCache
core:glass Haze 封装、T2 frozenGlass、材质 token
core:tvfocus 焦点组件、BringIntoViewSpec、焦点记忆
core:ambient 背景层、播放器、PixelCopy、帧取色、信号源画面
data:apps PackageManager、图标与 banner、整理
data:channels TvProvider、推荐通知、审批表
data:series 系列应用直连:发现、证书校验、approve / hide / init、行数据
data:widgets AppWidgetHost、绑定授权、网格布局、内置小组件
data:media 航拍片单、自己的视频、每日一图、缓存、许可信息
data:weather 天气、城市表、农历
data:restricted 儿童 / 长辈模式、PIN、计时
core:update 版本检查、下载校验、安装会话(仅 standard)
core:diag DiagLog、帧统计、诊断报告、devices.json
feature:home | apps | widgets | quicksettings
feature:settings | onboarding | phone-organizer
oem EPG 全量、BIND_APPWIDGET、亮度等
opt:shizuku 可选:小组件绑定授权
状态划分
HomeUiState:行、卡片、焦点 key、页面、浮层。不含颜色。ThemeState:from / to 两套配色加一个进度值,由 ThemeController 持有。组件在 drawBehind 或 graphicsLayer 中读取。BackdropState:当前与下一张背景、冻结模糊位图、视频状态。DeviceTier:启动时计算一次,设置中可以手动覆盖(§17)。
Room 表
AppEntry:组件名、排序、固定、隐藏、文件夹、显示名、自定义图标、最近启动SeedCache:sourceKey、algoVersion、seedArgb、lowChroma、updatedAtChannelApproval:包名、internalProviderId、状态、决定时间WatchlistItem:来源包名、internalProviderId、intentUri、标题、海报、加入时间(片单,只存本机)ChannelRowCache/ProgramCache:冷启动直接显示WidgetPlacement:appWidgetId、provider、页、位置、跨度AmbientClip:URL、时长、keyColor、海报帧、许可信息Folder:id、名称、排序(清空时删除)InputEntry:inputId、显示名、设备类型、是否固定UserClip:文件名、大小、时长、编码、keyColor、上传时间DailyImage:日期、urlbase、copyright 原文、wp、本地路径(最多 8 条)RestrictedProfile:模式(儿童 / 长辈)、允许的组件与输入、工作日 / 周末分钟数、允许时段、休息间隔与时长RestrictedUsage:日期、已用秒数、最后计时时刻、追加分钟、今天不限、休息开始时刻
用户偏好放在 DataStore,其中包括:PIN 的盐与哈希、天气城市与上次 IP 推测结果、画质选项、环境模式的片段与播放位置、开机目标、高对比焦点框、每日一图开关、按固件指纹记录的 PixelCopy 判定、devices.json 的 ADB 覆盖项、更新检查的退避状态。
分发与更新
standard 变体以 APK 侧载发行(项目网站与 GitHub Releases),没有应用商店的更新通道,所以自带更新检查。oem 变体随系统 OTA 更新,不含 core:update。
| 步骤 | 规则 |
|---|---|
| 检查 | 启动后及每 3 小时读取一个小的版本文件(versionCode、versionName、最低 API、下载地址、大小、SHA-256);失败按 30 秒 / 2 分钟 / 10 分钟 / 30 分钟退避,不提示。 |
| 更新说明 | 取已安装版本之后的全部更新说明,按版本倒序合并,显示在桌面设置的"更新"项。有新版本时该项显示"有新版本 {版本号}";主页不弹窗。 |
| 下载 | 用户确认后下载到应用私有目录,支持断点续传,完成后校验大小与 SHA-256。 |
| 校验 | getPackageArchiveInfo() 读取下载的包:包名与本应用相同;签名证书与已安装版本相同(API 28+ 用 GET_SIGNING_CERTIFICATES,之前用 GET_SIGNATURES);versionCode 大于已安装版本。任一不符即删除,不安装。 |
| 安装 | PackageInstaller 会话,APK 与对应的 .dm 写入同一会话。API 31+ 设置 setRequireUserAction(USER_ACTION_NOT_REQUIRED) 并声明 UPDATE_PACKAGES_WITHOUT_USER_ACTION:本应用是上一次的安装者时可以免确认;第一次从手动侧载的版本自更新,或系统不允许时,照常弹出安装确认。需要 REQUEST_INSTALL_PACKAGES,API 26+ 要用户允许"安装未知应用"。会话失败时,API 24+ 回退为 ACTION_VIEW + FileProvider 交给系统安装器(不带 .dm)。 |
| Baseline Profile | 发行时为 APK 生成 .dm(dex metadata,API 28+ 生效):API 28–30 与 API 31+ 各一份(profile 格式不同),安装时按设备 API 级别选择。 |
| 首次启动核对 | 更新或侧载后首次启动,用 androidx.profileinstaller 的 ProfileVerifier 查看是否已按 profile 编译;结果只写入诊断日志,界面上不提示。 |
| ADB 安装 | 开发与测试中用 adb install 安装时没有 .dm。安装并启动一次后执行 adb shell cmd package compile -m speed-profile -f <pkg>,让 profile 立即生效。 |
| 旧系统证书 | API 23–24 的系统证书库没有 ISRG Root X1。更新、天气与图片请求的服务器若使用 Let's Encrypt 证书,这两级上要用应用内置的根证书(API 24 用 network_security_config,API 23 用自定义 TrustManager)。 |
| 签名密钥 | 发行签名密钥离线保存并备份;密钥一旦更换,已安装用户无法通过应用内更新升级(§20)。 |
机型修正数据
机型差异集中在随包发布的 assets/devices.json,按 Build.MANUFACTURER + Build.MODEL 匹配(必要时加 Build.DEVICE 或 Build.FINGERPRINT 前缀),代码中不写机型判断。
| 字段 | 用途 |
|---|---|
inputLabels | 信号源的显示名与设备类型(例如把固件的内部名称显示为"HDMI 2") |
bootDelayMs | 开机后等待输入服务就绪的时间(开机目标、信号源列表,§14) |
pixelCopy | ok / black / slow;预置判定,免去首次探测(§13) |
firmwareInputPanel | 信号源键交给固件;固件信号源界面的组件名(§14) |
decoderDelayAfterInputMs | 离开 HDMI 后打开解码器前的等待(§13) |
noThirdPartyDream | 系统不列出第三方屏保,启用桌面内屏保(§13) |
- 单机覆盖:
adb shell am broadcast -n <pkg>/.config.ConfigReceiver --es device_profile '{…}'(与 PIN 重置同一个接收器,要求发送方持有 WRITE_SECURE_SETTINGS);覆盖项存入 DataStore,优先于随包数据;传空字符串清除。 - 可见性:诊断报告列出命中的条目与覆盖项(§14)。
性能分级与预算
首次启动时,根据设备信息和一次约 2 秒的离屏测试(测试 T1 模糊时的帧时间)确定档位。运行中,如果连续 3 秒 P95 帧时间超过预算,自动降一档。
| 档位 | 判定(参考) | 玻璃 | Haze 模式 | 模糊区域上限 | 卡片预览视频 | 环境模式 |
|---|---|---|---|---|---|---|
| 低 | isLowRamDevice 或内存 ≤ 1.5GB | T3 | — | 0 | 关 | 1080p SDR,或只用壁纸 |
| 中 | API 23–30;或 API 31+ 但测试未达标 | T2 | — | 不限(冻结位图) | 默认关 | 按解码能力,最高 4K SDR |
| 高 | API 31+,测试 P95 ≤ 12ms | T1,视频背景上用 T2 | Performance(可改 Balanced) | 3 | 开,焦点停留 1.2 秒 | 4K HDR / 杜比视界(设备支持时) |
| 指标(中档设备,60Hz) | 目标 | 测量方式 |
|---|---|---|
| 冷启动到首帧(含缓存的行) | ≤ 1.2 s | Macrobenchmark StartupTimingMetric,配合 Baseline Profile |
| 行内连续移动焦点 | P95 帧时间 ≤ 16.7 ms,无连续丢帧 | FrameTimingMetric |
| 主题过渡期间 | 不发生列表重组 | Compose 重组计数(测试构建) |
| 单个来源取色 | ≤ 2 ms(缩略图到种子色,不含解码) | 微基准 |
| 配色生成 | ≤ 1 ms | 微基准 |
| T2 冻结模糊,每次换背景 | ≤ 12 ms,在后台线程 | API 23 设备实测 |
| 浏览态内存 | PSS ≤ 180 MB | dumpsys meminfo |
| 第三方小组件重新加载 | 每 3 秒最多 1 次 | 日志计数 |
启动、刷新率与内存
- Baseline Profile 必须实际生效:冷启动目标以"已按 profile 编译"为前提。侧载发行靠安装会话附带的 .dm(§16"分发与更新");验收时在侧载安装后检查
ProfileVerifier的结果,或adb shell dumpsys package dexopt中本应用为 speed-profile。Macrobenchmark 用CompilationMode.Partial()测量同样的状态。 - 刷新率:API 31+ 从当前显示模式的
getAlternativeRefreshRates()(可无缝切换的刷新率)中选不超过 120Hz 的最高值,通过窗口的preferredDisplayModeId设置;不选择需要重新同步、会黑屏的模式切换。API 23–30 不改显示模式。帧时间预算按实际刷新率换算。 - onTrimMemory:
TRIM_MEMORY_UI_HIDDEN(每次打开应用都会收到)时不释放,保证回到主页时首屏不必重新生成;TRIM_MEMORY_RUNNING_LOW、RUNNING_CRITICAL以及BACKGROUND和更高级别时,清空图片内存缓存、冻结模糊位图和取色缩略图,保留 Room 与磁盘缓存。API 34+ 不再发送 RUNNING_* 级别,以 BACKGROUND 为准。 - 冷启动:首帧只用 Room 里缓存的行和种子色(ChannelRowCache / ProgramCache / SeedCache);TvProvider、系列直连和小组件在首帧之后加载;字体在 Application 启动时于后台预热(aerial-tv
FontWarmup.kt);首帧前不初始化 Media3、Haze 模糊和网络客户端;不经过单独的启动页跳转。
兼容矩阵
| 能力 | API 23 | API 24–25 | API 26–30 | API 31+ |
|---|---|---|---|---|
| 玻璃 | T2 / T3 | T2 / T3 | T2 / T3 | T1 / T2 / T3 |
| 取色来源 | 图标整图、海报、壁纸、片单 keyColor | + 视频帧(PixelCopy) | + 自适应图标分层 | 同左 |
| 频道 | 推荐通知(可选) | 推荐通知(可选) | READ_TV_LISTINGS | 同左 |
| Watch Next | — | — | TvProvider(Google TV 上可能为空) | 同左 |
| 系列应用频道 | 直连行数据 | 同左 | TvProvider + 直连控制;没有 TvProvider 时直连行数据 | 同左 |
| 第三方小组件 | 支持;内部焦点由宿主补设 | 同左 | + focusable="auto"、应用发起的添加请求、getAppWidgetIds 对账 | + setColorResources、previewLayout、目标格数;API 35 生成式预览 |
| 小组件绑定授权 | 五条路径与 API 级别无关(§12);Shizuku 需要其自身支持的系统版本 | |||
| HDR 能力查询 | 只看解码器 | + Display.getHdrCapabilities() | 同左 | 同左 |
| 设为默认桌面 | HOME 选择器 | 同左 | 同左 | API 29+ 可用 RoleManager.ROLE_HOME(电视上是否有弹窗待验证) |
| 儿童模式:从后台拉起锁屏 | 无后台启动限制 | 同左 | API 26–28 同左;API 29–30 依赖默认桌面豁免(M0) | 默认桌面豁免(M0);或 ADB 授予悬浮窗 |
| 应用内更新 | PackageInstaller 会话 | + 回退 ACTION_VIEW + FileProvider | API 28–30 附带 .dm | + .dm;条件满足时免确认 |
| 帧时间统计 | Choreographer 估算 | FrameMetrics | 同左 | 同左 |
| 刷新率 | 不改 | 不改 | 不改 | 无缝切换到最高刷新率(≤ 120Hz) |
| 农历 | 内置 1901–2100 数据表,各级一致;API 24+ 的测试中与 ICU ChineseCalendar 比对 | |||
| ISRG Root X1 | 内置 | API 24 内置(network_security_config);API 25 起系统自带 | 系统自带 | 系统自带 |
| 后台行为 | — | — | 后台服务限制:ContentObserver 只在前台注册,后台刷新用 WorkManager | 同左 |
测试与验收
自动化
- 对比度扫描:1000 个随机种子 × 4 个变体 × 3 个对比度级别,断言 onPrimary 对 primary ≥ 4.5:1,onSurface 对 surface ≥ 7:1,焦点光环对 surface ≥ 3:1。
- 取色回归:固定图标与海报样本集,记录种子色;算法版本号变化时整体更新快照。
- 截图测试:T2 / T3 用 Roborazzi(JVM);T1 用设备上的 instrumentation 截图。
- 频道:伪造 TvProvider(ProviderTestRule),写入他人包名的行、
searchable = 0的行,以及带 selection 时抛异常的情况。 - 系列应用:myBV、mydv、mytv-android 实机联调:默认频道自动上屏、稍后再看确认页、移除回传、mytv-android 初始化、没有 TvProvider 的盒子上的直连数据、证书不符时拒绝。
- 焦点:方向键序列脚本(UI Automator),检查焦点记忆、恢复链和 BACK 链没有死循环。
- 小组件:自带测试 provider(含配置页、集合视图、只有 ImageView 点击的布局),覆盖五条绑定路径、取消配置、provider 卸载、RemoteViews 异常与内部焦点。
- 性能:Macrobenchmark 启动与滚动,Baseline Profile 进 CI。
- 受限模式:计时(推进时钟、亮屏 / 关屏、进程重启后补算)、5 / 1 分钟提醒、锁屏倒计时、PIN 输错锁定、HOME 后重新上锁;在 API 28 / 31 / 34 模拟器上验证到时从后台拉起锁屏。
- 农历:1901–2100 逐日与 ICU ChineseCalendar 比对(API 24+)。
- 更新:本地 HTTP 服务提供版本文件与包,覆盖包名不符、签名不符、versionCode 不升、SHA-256 不符和退避节奏。
- 手机整理页:视频限额、无视频轨文件的拒绝,VPN 接口与 198.18.0.0/15 的地址排除。
设备矩阵
- Android TV 模拟器:API 23、28、31、34
- 低档:Mali-450 级盒子(API 23–28)
- 中档:Mali-G31 / G52 级(API 30–31)
- Google TV 参考设备(API 34+):验证频道数据量与 Engage 的影响
- HDR 电视一台:环境模式与 PixelCopy 遇 DRM 时的行为
- 带 HDMI 输入的电视一台:信号源画面壁纸、开机 / 唤醒目标、离开 HDMI 后的解码器交接
验收清单
- 只用方向键能走完所有流程,无焦点丢失
- 任何取色结果下文字都可读
- 没有频道、没有网络时,主页依然完整
- T1 掉帧时能自动降到 T2
- 小组件崩溃不影响桌面
- 未授权设备上,从选择器到放置第三方小组件不超过 3 步(Shizuku 路径)
里程碑、风险与 ADR
| 阶段 | 内容 | 出口条件 |
|---|---|---|
| M0 技术验证(2 周) |
① Haze T1 在 3 种 SoC 上的帧时间;② T2 冻结玻璃的对齐与换背景耗时;③ PixelCopy 抓 SurfaceView(API 24 / 26 / 31,含 DRM 内容);④ READ_TV_LISTINGS 在 AOSP ATV 与 Google TV 上的可见范围;⑤ ACTION_REQUEST_CHANNEL_BROWSABLE 在系统桌面仍安装时由谁接收;⑥ 小组件:目标设备上 ACTION_APPWIDGET_BIND 有无处理者、Shizuku 与 ADB 授权、常见手机小组件在电视上的布局与焦点表现;⑦ material3 1.5.0-alpha25 在 Compose 1.12.1 下的运行验证,以及 MaterialKolor 模块的 minSdk 与依赖树;⑧ 系列应用直连联调(myBV、mydv、mytv-android);⑨ 儿童模式的执行手段:默认桌面从后台拉起锁屏(API 29 / 31 / 34 模拟器与目标固件),ADB 授予悬浮窗与使用情况访问后的行为;⑩ HOME 兼容:COMMON_HOME、HOME.CC 与 TCL meta-data 在测试固件上的效果,cmd package set-home-activity 与 cmd role add-role-holder 是否可用;⑪ IP 推测服务的使用条款 | 每项给出数据或结论;未通过的项按"风险"列的预案处理 |
| M1 骨架 | 主页结构、焦点引擎(含按键音效、触屏与鼠标)、应用行与应用页(含文件夹)、顶栏、T3 材质 | 只用方向键可完成全部基本操作 |
| M2 视觉 | 动态取色全链路、T1 / T2、背景切换、Expressive 动效与形变、高对比焦点框 | 对比度扫描通过;中档设备满足帧时间预算 |
| M3 系统能力 | Channel(A / B)、审批、继续观看、片单、系列应用直连;第三方小组件全流程(选择器、授权、网格编辑、内部交互)与内置小组件(含时钟与天气、农历、24 小时制) | Google TV 与 AOSP ATV 各一台验收 |
| M4 环境 | 环境模式(含真机抓帧、解码器交接、画质与缓存、屏保衔接与兜底)、自己的视频、必应每日一图、快速设置、HDMI(含实时画面壁纸、开机 / 唤醒目标、信号源键)、手机整理、机型修正数据、儿童 / 长辈模式与家长 PIN | 片源许可清单完成;受限模式按 M0 结论的路径通过验收 |
| M5 加固 | 性能、内存(onTrimMemory)、刷新率、崩溃隔离、Baseline Profile 与 .dm、应用内更新、诊断导出、首次启动引导(含默认桌面页) | 验收清单全部通过 |
| 风险 | 影响 | 预案 |
|---|---|---|
| 目标设备 GPU 无法支撑 T1 | 玻璃质感下降 | 默认 T2,T1 只在测试达标的设备上启用 |
| Google TV 上频道数据稀少 | 主页内容单薄 | 应用行 + 壁纸 / 航拍成为主体;合作方直连(来源 D) |
| 电视上没有小组件绑定界面 | 首次添加第三方小组件需要一次授权 | 五条授权路径(§12);首次引导里提供;oem 变体直接持有权限 |
| 手机小组件在电视上不好用(触屏布局、无焦点) | 操作不便 | 宿主补设焦点与焦点框;OK 兜底点击 |
| material3 停在 1.5.0-alpha25 | 拿不到之后的修复;实验 API(MaterialShapes、LoadingIndicator)可能与正式版不同 | resolutionStrategy.force 锁版本;实验 API 只在 core:designsystem 内使用,便于将来替换;minSdk 提到 24 时再升级 |
| 取色结果与品牌色冲突(如某 App 图标) | 观感偏差 | 允许给特定包名配置种子色;默认 TonalSpot 压低彩度 |
| 航拍片源许可 | 无法发行 | 自有素材、壁纸模式兜底 |
| 必应每日一图的使用条款 | 图片版权属各自的权利人,必应只允许用作桌面壁纸;条款变化或收到投诉时需要停用 | 只在本机显示、不再分发;遵守 wp 字段;显示 copyright 原文;设置中可关闭,失败或关闭时回到内置壁纸;必要时在后续版本移除此来源。此风险已确认接受 |
| Open-Meteo 免费接口限非商业使用 | oem 或商业发行不能使用免费接口 | standard 用免费接口并署名;oem 签付费计划或换供应商;天气数据源做成可替换的接口 |
| standard 变体无法强制执行儿童模式 | 额度用完后拉不回锁屏,或拦不住跳转到的应用 | M0 验证默认桌面的后台启动豁免;ADB 授予悬浮窗与使用情况访问;设置页如实显示状态;需要强制时用 oem |
| PixelCopy 在部分机型全黑或很慢 | 返回界面时背景全黑或主页卡顿 | 150 ms 预算、全黑检测、按固件记住、预置关键帧回退(§13) |
| 侧载更新的签名密钥丢失或泄露 | 无法再推送更新,或被冒充 | 离线保存并备份;安装前校验签名证书与 SHA-256(§16) |
| 国内固件不认第三方桌面 | 按 HOME 回到系统桌面 | 额外的 HOME 类别与 meta-data、默认桌面页与 ADB 命令(§14);oem 预装 |
ADR
参考来源
均为一手来源(官方文档、源码、发布说明),访问日期 2026-11-02。9to5Google 的报道只用于描述 Google TV 界面的变化时间线。
参考项目与库
- pengchujin/aerial-tv(commit 6182cf2,1.79 / versionCode 102):README、
app/build.gradle.kts、app/src/main/AndroidManifest.xml、app/src/standard/AndroidManifest.xml;源码LiveWallpaper.kt、AerialStream.kt、AerialSet.kt、Aerials.kt、FilmAfterInput.kt、Screensaver.kt、SystemScreensaver.kt、Wallpapers.kt、Gradients.kt、SourceWallpaper.kt、BootOpen.kt、HomeScreen.kt、PhoneServer.kt、ScreensaverVideos.kt、FolderView.kt、KidsMode.kt、KidsWatch.kt、Restricted.kt、Weather.kt、Lunar.kt、Sounds.kt、Pressable.kt、Updates.kt、SelfCompile.kt、DiagLog.kt、FrameStats.kt、DeviceProfile.kt、ConfigReceiver.kt、MainActivity.kt、UhdCanvas.kt、CecHome.kt - Haze 文档:blur、platforms、performance、migrating-2.0
- Haze issues:#1368、#798、#249、#715、#1509
- material-color-utilities
- MaterialKolor
- material3-android 1.5.0-alpha25(Gradle module 元数据、AAR 清单与源码包)
- Shizuku-API
- TvInputManagerService.java(requestChannelBrowsable 的广播方式) · frameworks/base 的 AndroidManifest(受保护广播列表) · ActivityManager.java(checkComponentPermission:未导出组件的调用者限制)
- RenderScript Intrinsics Replacement Toolkit
- rockon999/LeanbackLauncher(推荐通知读取的先例)
- Open-Meteo 使用条款(免费接口限非商业使用;数据 CC BY 4.0) · 必应 HPImageArchive(url、urlbase、copyright、wp 字段)
AOSP 源码
- TvProvider:TvProvider.java、AndroidManifest.xml
- device/google/atv:tv_core_hardware.xml
- device/google/atv:TvFrameworkOverlay config.xml(config_enableAppWidgetService)
- SystemServer.java(AppWidgetService 启动条件)
- AppWidgetServiceImpl.java(绑定权限、授权名单的保存与加载)
- ShortcutRequestPinProcessor.java(requestPinAppWidget 与 accept)
- cmds/appwidget(grantbind 命令) · Shell 的 AndroidManifest
- TvSettings 的 AndroidManifest · Settings 的 AndroidManifest(ACTION_APPWIDGET_BIND)
- SystemUI:TvSystemUI 的 TVSystemUICoreStartableModule.kt
- BackgroundActivityStartController.java(默认桌面与悬浮窗权限的后台启动豁免)
developer.android.com
- Compose Material3 版本说明 · Compose UI 版本说明 · TV 库版本说明
- TV:Compose 列表与 BringIntoViewSpec
- TV 设计:焦点系统 · 布局 · 应用图标与 banner
- 主屏幕频道 · 旧版推荐行 · Google TV 适配
- Engage SDK FAQ
- Media3:Surface 类型
- AppWidgetHostView · RenderEffect · PixelCopy
- 从 RenderScript 迁移
- 后台启动 Activity 的限制 · SessionParams.setRequireUserAction · Display.Mode.getAlternativeRefreshRates · ProfileVerifier · AudioManager.playSoundEffect
设计与报道
- Expressive Design: Google's UX Research
- Material 3(motion、shape、typography)
- 9to5Google:Google TV 主页改版(2025-09-19、2025-11-13)、圆形应用图标(2024-02-28)、Android TV 16 / M3E(2025-05-20)、Gemini Tab(2026-03-24)