Releases: hxzhang2000/NASMusicTV
Release list
NASMusicTV v2.36.4
[v2.36.4] - 2026-09-21
E37「分子」:去掉刻画抖动 · 分子式改专用排版 · 修好分子轮换
Fixed
- E37「分子」刻画阶段整体抖动、原子随节拍闪现(
visualizer/renderers/MoleculeRenderer.kt):
旋转角与描线进度原用「绝对now× 含pulse的速率」算相位 ——now是开机毫秒
(1e7 量级),pulse的每帧抖动被放大千万倍,一帧能跳几十弧度。改为 dt 累加
(rotAccum/drawAccumMs),且 DRAW 期旋转恒速、不吃 pulse
(逐步点亮的动画本身已经够看,再叠律动就乱) - 右侧分子式下标位置与字母对不上(新增
visualizer/renderers/ChemicalFormula.kt):
① 绘制用Paint.Align.RIGHT画 run,而runX是 run 左缘 → 每个 run 又被左移
自身宽度,下标整块压到前一个字母身上;② 沿用数学排版器FormulaLayout的
SUB_LOWER = 0.25em,小于下标字形字高(≈0.45em)→ 下标骑在主基线上。
化学式改走专用排版器:runX= 左缘配Align.LEFT,下标下沉量由
Paint.getTextBounds实测的数字墨迹高度算出(所有下标共用同一条基线),
并按文字带宽自动缩字号 - E37 只画一个分子、从不轮换:
pickNext()只在onEnter()调用过,
空场 → 刻画的状态迁移没换分子(与「洗牌换下一个分子」的设计不符)。GAP → DRAW 时补上 - 笔头高亮画在错的原子上:原按原子下标取「DFS 序位」,改为
seqToAtom反查
Added
ChemicalFormula—— 化学式专用排版器(纯 JVM,文本测量与字体度量均注入,单测可断言)ChemicalFormulaTest(化学式排版,含 run 左缘连续性 / 下标下沉量 的负向自证)MoleculeMotionTest(运动相位:DRAW 恒速、单帧增量有界、dt 钳制)
[v2.36.3] - 2026-09-21
手机端手势跟手化 · 横屏图标修正 · 新增 E37「分子」频谱效果
① 竖屏播放页「封面 ⟷ 歌词」与曲库子 TAB 的左右滑切换改为真 pager 跟手:
拖动中当前页划走、相邻页同时跟进,松手过半页吸附(原实现松手后才播横推动画)。
② 手机横屏态方向切换图标上下翻转(+90° → -90°),与系统横屏图标方向一致。
③ 新增频谱效果 E37「分子」:左侧描线刻画化学分子结构(52 个分子库),
右侧显示分子式(真下标排版)+ 中文名;节奏对齐「催眠」效果
(刻画 8s → 凝视 3s → 溃散 2.4s → 空场 0.5s,随机轮换)。
Added
- E37
MOLECULE「分子」频谱效果(visualizer/renderers/MoleculeRenderer.kt):- 分子库 52 个分子(
MoleculeLibrary):水/甲烷/氨/甲醇/乙烯/乙炔/甲醛/二氧化碳/乙醇/乙酸/
臭氧/三甲胺/二甲硫醚/二甲基汞/氟利昂-12/顺铂/苯/甲苯/苯酚/苯甲醛/苯乙烯/苯胺/
对乙酰氨基酚/阿司匹林/咖啡因/多巴胺/血清素/六氟化硫/立方烷/金刚烷/三聚氰胺/二茂铁/
亚铁氰化钾/腺嘌呤/鸟嘌呤/胞嘧啶/胸腺嘧啶/尿嘧啶/甘氨酸/丙氨酸/香草醛/柠檬烯/薄荷醇/
水杨酸甲酯/TNT/DDT/尿素/丙酮/乙醚/乙酸乙酯/葡萄糖/维生素 C - 描线刻画:从重原子出发沿键 DFS 描线,原子按次序逐个点亮、键沿次序生长,
笔头高亮跟随(对齐催眠 §8.4 手感) - 频谱映射:每原子 = 一个频段(重原子 → 低频),能量驱动原子半径/三层光晕/描边;
键亮度粗细随两端能量、键上光点随节拍流动;支持双键(平行双线)与芳香离域内圈 - 右侧文字带:分子式经
FormulaLayout排出真化学式(H₂O 真下标)+ 中文名(强调色) - 四态状态机与节奏常量完全对齐 E25「催眠」(DRAW 8s / HOLD 3s / DISSOLVE 2.4s / GAP 0.5s)
- BASIC 档(三档画质全可用);新增
MoleculeLibraryTest数据一致性单测(6 项)
- 分子库 52 个分子(
Changed
- 竖屏播放页「封面 ⟷ 歌词」切换改为真 pager(
NowPlayingScreen.kt):
原AnimatedContent松手后才播横推动画 → 改为连续pageFraction驱动双页实时平移,
拖动跟手、松手过半页吸附(220ms);点模式指示器走同一条平滑吸附动画 - 曲库子 TAB 切换改为真 pager(
LibraryScreen.kt):
连续页索引tabPageFraction驱动,拖动中当前页划走、相邻页同时组合平移
(最多 2 个 Tab 子树常驻);松手按最近页吸附;点击 TAB 行也走平滑滑动动画;
平移计算放 draw 相位(graphicsLayer),拖动/动画期间不触发逐帧重组 - 横屏态方向切换图标翻转(
PhoneTopBar.kt):OrientationToggleIcon横屏态
旋转 +90° → -90°(真机反馈上下颠倒)
Fixed
- 曲库 pager 平移方向反了(v2.36.3 开发中真机反馈):
translationX公式
(fraction − 页索引)→(页索引 − fraction),手指左划现在正确滑向右侧 TAB
NASMusicTV v2.36.2
注意:这个版本的签名改了,各位需要卸载掉原来的软件,再重新安装使用。但这样会清除掉所有的本地数据,所以卸载前一定要导出数据,等安装新的版本后,再导入使用!!!
[v2.36.2] - 2026-09-20
手机端 4 个真机问题修复 · 老备份导入容错
① 竖屏文字输入弹窗排版:按钮行与搜索历史行超宽被裁到屏幕外 → 竖屏改自动换行 + 键盘避让。
② 天气电台歌曲条目的内嵌按钮补齐为「⬇ ♡ ☰ +」,与曲库 / 专辑详情等页面一致。
③ 以前保存的备份文件导入失败(历史枚举名CLASSICAL_WAVE经 Gson 反序列化变成null
→ 后续.name抛 NPE → 整份导入回滚),并修复手机端点「恢复」后看不到任何提示。
④ 手机频谱页左上角关闭按钮点击无效(tv-material3IconButton在触摸设备上不响应点击)。实现细节见
docs/technical-overview.md§10.172、§10.173、§10.174、§10.175。未实施部分:① 4 项修复的真机复验;② 备份文件「部分导入成功」的可回滚性
(importBackupData先写 serverConfig 再写 appSettings,后者失败时前者已落盘)。
Fixed
- 老备份文件导入失败:备份 JSON 里存的是枚举常量名,历史版本删改过枚举常量
(VisualizerTheme的CLASSICAL_WAVE已不存在)。Gson 2.10.1 的枚举适配器在名字找不到时
返回null而不抛异常,再经反射写进字段、绕过 Kotlin 非空检查
→AppSettings.visualizerTheme(声明非空)变null→importBackupData()里
settings.visualizerTheme.name抛 NPE →dataStore.edit {}事务回滚 → 整份备份导入失败。
VisualizerTheme.LEGACY_MAP原本只服务fromKey()(读 DataStore 那条路),
覆盖不到 Gson 反序列化 —— 这是「老备份导入失败」的根因 - 手机端点「恢复」静默失败无提示:备份结果消息渲染在「备份文件列表下方」,
恢复入口却在列表每一行里 → 消息被挤到屏幕外,且 4s 后自动消费,用户完全看不到 - 手机频谱页左上角关闭按钮点击无效:
VisualizerStage用的是 tv-material3IconButton,
其Surface(onClick=)走Modifier.tvClickable,故意不挂Modifier.clickable
(为让enabled=false时仍可聚焦),只提供 D-Pad 按键 + 焦点 +semantics.onClick(无障碍)
→ 触摸点击永远不触发onClick。改用项目自建的FocusableSurface(内部combinedClickable) - 竖屏文字输入弹窗展示不全:对话框内按钮是固定宽度的,竖屏可用宽只有 ≈291dp,
而系统 IME 模式操作行合计 426dp、自制键盘底部功能行 688dp、搜索历史行 ≈630dp
→ 用Row时首尾元素被直接裁到屏幕外 - 天气电台歌曲条目只有下载按钮:
UnifiedSongRow的按钮按「回调是否为 null」决定是否渲染,
本页此前只传了downloadState+onDownload,缺onToggleFavorite/onToggleQueue/
onAddToPlaylist/onDeleteDownload
Changed
- 新增备份专用容错 Gson
data/prefs/BackupGson.kt:三级回落(当前枚举名 → 枚举自带兼容映射
→ 首个常量),永不返回 null;备份导出 / 导入两条路径改用它(BackupViewModel) AppPreferences.importBackupData()增加最后一道兜底:visualizerTheme/visualizerQuality/
defaultPlayMode/defaultNetworkSource四个枚举字段为null时回落各自默认值,
不再让单个字段拖垮整份导入- 竖屏对话框改自动换行:
TextInputDialog新增WrapButtonRow(竖屏FlowRow、其余Row),
键盘行 / 功能行 / 历史行均按竖屏换行;竖屏补imePadding()做键盘避让 - 竖屏搜索历史项触摸目标由 32dp 抬到
portraitTouchTarget(32.dp)(56 Compose dp) - 备份结果消息上移到「数据管理」分区顶部;出现新消息时分区列表自动滚回顶部;
失败消息不再 4s 自动消费(成功提示仍自动消失) WeatherRadioScreen新增favoriteIds/queueSongIds/onToggleFavorite/onToggleQueue/
onAddToPlaylist/onDeleteDownloadSong参数,WeatherRadioBranch与LibraryBranch同源接线- 频谱页返回按钮的无障碍描述由硬编码
"返回"改为R.string.common_back - 版本号 2.36.1 → 2.36.2(versionCode 155 → 156)
Test
- 新增门禁
BackupGsonTest(9 例):第 1 例是负向自证(先证明默认 Gson 确实会把历史枚举名
解析成null,否则根因判断有误),其余覆盖历史名迁移、三个回落层级各自的未知名不为 null
(枚举自带Default/fromKey返回 null / 无兼容映射)、老备份缺字段保留默认值、
导出仍写enum.name、整份BackupData反序列化 - 门禁结果:
testDebugUnitTest874 例 / 0 失败、lintDebug0 Error / 272 Warning、
assembleReleaseBUILD SUCCESSFUL →NASMusicTV-release-v2-36-2.apk
[v2.36.1] - 2026-09-20
车机蓝牙媒体按键修复 · 手机横竖屏布局修复
① 修复 release 包下车机蓝牙(AVRCP)暂停 / 上一曲 / 下一曲全部失效的问题:
PlaybackService.onConnect不再拒绝系统控制器,安全边界改到可用会话命令。
② 修复手机横屏仍渲染竖屏界面(底部残留 mini 播放条与底部导航栏)的问题。
③ 手机横屏补上方向切换按钮,使横屏可以切回竖屏。
④ 手机竖屏排版调整:底部导航按钮居中;播放页封面 / 歌词两个模式的 Chip 归位。
⑤ 手机竖屏设置入口从底部导航移到顶栏齿轮按钮(底部导航 6 → 5 项)。
⑥ 手机端曲库页面支持在内容区左右滑切换子 TAB(竖屏 + 横屏)。实现细节见
docs/technical-overview.md§10.165、§10.168、§10.169、§10.170、§10.171。未实施部分:① 车机「未播放时按播放键拉起续播」(需播放队列持久化 +
onPlaybackResumption,故未注册 manifestMediaButtonReceiver);
② 修复后的车机实机复验(需 release 包 + 车机);
③ 手机横屏的视觉规格是否完全对齐 TV(密度缩放 / 字号档 / 网格列数三处仍按手机规格)。
Fixed
- 车机蓝牙媒体按键全部失效(release 包):
PlaybackService.onConnect不再拒绝系统控制器,
新增player/MediaSessionAccessPolicy.kt承载准入策略 - 可用会话命令改以
DEFAULT_SESSION_AND_LIBRARY_COMMANDS为基线「只增不减」 - 小尺寸触摸目标护栏的同行写法盲区(
SmallTouchTargetScanTest+audit_small_touch_target.py) - 手机横屏仍渲染竖屏界面:
MainActivity.attachBaseContext()不再下发整份Configuration - 手机横屏缺方向切换按钮:新增
OrientationToggleButton,TvTopNavBar仅在手机横屏时渲染 SmallTouchTargetScanTest的未闭合块注释语法错误- 手机竖屏底部导航按钮左对齐:
PhoneNavBar内容容器补fillMaxSize()
(FocusableSurface内部Box未指定contentAlignment,默认TopStart) - 手机竖屏播放页 Chip 归位:封面页「逐行 / 逐字」高亮 Chip 移入歌词页工具条;
封面页移除「收藏」「播放队列」Chip(分别与心形图标、底部导航重复);
封面页「封面」文案改为「频谱」(原文案与其onEnterVisualizer行为不符);
歌词工具条由歌词框下方上移至右上方并右对齐,与横屏 TV 一致 - 删除因上述改动失去引用的字符串资源
np_mode_cover、action_unfavorite(中英双语)
Changed
- 手机竖屏设置入口迁移:
PhoneNavBar移除「设置」项(6 → 5 项,各项weight(1f)
自动等分占满整宽);PhoneTopBar右上角新增齿轮按钮,位于横竖屏切换按钮左侧,进入设置页 - 齿轮按钮进入设置前先
closeSettingsSection():navigateTo不清二级页状态,
否则从某个设置子页点齿轮会「看起来没反应」 - 新增无障碍文案
nav_settings_cd(中英双语) PhoneNavBar的类级 KDoc 由PhoneNavItem上移到PhoneNavBar(原先错挂在 data class 上)- 手机端曲库子 TAB 左右滑切换:内容区新增水平拖拽手势(阈值 48dp),
左滑 → 下一 TAB、右滑 → 上一 TAB,到两端不循环;竖屏与横屏均生效,TV 端不加手势
Test
- 新增门禁
MediaSessionAccessPolicyTest(8 例,含源码扫描「不得出现 reject」+ 三层自证)
[v2.36.0] - 2026-09-19
手机竖屏 UI 适配与横竖屏切换 · Release 独立签名
引入
UiMode形态因子(TV / 手机竖屏 / 手机横屏),逐页适配竖屏,并提供两层方向控制
(设置项三选一 + 顶栏单击循环);Release 改用独立签名、CI 支持正式签名。设计见
docs/archive/phone-portrait-ui-plan.md,维护约定见docs/conventions-adaptive-ui.md,
实现细节见docs/technical-overview.md§10.162–§10.164。未实施部分:① 修复后的实机视觉复验;② 详情页下滑返回手势;③ 缩放系数 0.82 → 0.88;
④ 平板TabletPortrait独立分档。
Added
- 形态因子
UiMode(ui/theme/UiMode.kt):TV / PhonePortrait / PhoneLandscape+LocalUiMode - 纯函数方向决策:
deriveUiMode/resolveOrientation/ScreenOrientationPref.nextOnToggle - 屏幕方向偏好(
data/prefs/DisplayPrefs.kt):三态偏好,冷启动零 IO 同步读 - 竖屏骨架:顶栏
PhoneTopBar、6 项底部导航PhoneNavBar、迷你播放条MiniPlayer - 设置页两级化:一级分区列表 + 二级返回头,竖屏不再渲染侧栏
- 竖屏播放页
NowPlayingPortrait:封面 / 歌词双模式、左右滑切换、低频入口收进「⋯」菜单 - 竖屏队列页 / 详情页 / 歌单管理页:各自改为竖屏版式(两级化、操作行可横滑)
- 自适应工具(
ui/components/CommonComponents.kt):adaptiveColumns(Of)/AdaptiveLayout/
responsiveDialogSize/portraitTouchTarget - 门禁
ScreenUiModeCoverageTest:新增 Screen 未引用自适应 API 即失败(豁免标记NasScreenUiMode-exempt) - 约定文档
docs/conventions-adaptive-ui.md - UI 文案 26 条(中英双语)
Changed
- 不再强制横屏:删除写死的
SENSOR_LANDSCAPE,改按偏好 + 是否全屏页动态决策 AndroidManifest.xml改screenOrientation="unspecified"+ 新增configChanges,旋转不再重建 Activity- 竖屏显示系统栏,TV / 手机横屏 / 沉浸模式 / 全屏页仍隐藏
- 旋转为硬切,不做过渡动画
- BACK 优先级:竖屏设置二级分区与歌单管理二级页由各自 handler 先消费
- 各页竖屏 padding 32 → 16dp
- 曲库页顶部改三行;电台页顶部行拆两行;搜索页来源 Chip 行可横滑
- 列数统一走
adaptiveColumns(电台网格、Shimmer 骨架网格) - 对话框族统一走
responsiveDialogSize(14 处) - 网盘页搜索框改整行、服务器连接卡片改限宽
- 首页竖屏隐藏「当前播放」卡片、统计卡改 2×2 网格、横向卡片 160 → 140dp
- 热力图新增可选放大(仅手机竖屏开启)
- 竖屏底栏新增「队列」直达入口(5 → 6 项),未选中项改
TextPrimary - 竖屏播放页封面模式控制区贴屏幕底部
- 竖屏播放页左右滑切换覆盖整块内容区,带方向语义
- 竖屏歌曲条目改为两行
- 手机横屏首页不再显示全宽「当前播放」播放条
Fixed
- 竖屏按钮 / 文字「看不清」:
FocusableSurface同时下发LocalFocusableContentColor
与LocalContentColor(全仓库 10 处,TV 端同样受益) - 手机触摸「粘滞焦点态」:焦点相关视觉统一收敛到
isFocused && isTVDevice() - 竖屏触摸目标不达标:统一改走
portraitTouchTarget(基线 56dp),覆盖顶栏 / 播放条 /
播放页 / 队列行 / 搜索框 / 各对话框 - 竖屏歌曲行操作按钮触摸目标不达标
- 设置页「删除备份」确认弹窗未走
responsiveDialogSize ExportDeviceDialog设备列表项padding削热区FocusableSurface聚焦缩放未按设备分档(P2-34)PlayerControls.kt遗留调试日志FocusableSurface的 TV 判定补android.hardware.type.television- 自建底部弹层补齐 BACK 注册
- 竖屏封面模式歌名区预留改按实际字号动态计算
- 竖屏歌曲行第一行改
heightIn(min = 88.dp) - 播放页模式指示器圆点触摸目标过小
- 竖屏底栏英文标签被裁:新增底栏短标签
isTVDevice()加remember缓存
Test
- 新增门禁:
UiModeTest、ScreenUiModeCoverageTest、FocusableSurfaceColorContractTest(均含负向自证) - 全量
testDebugUnitTest864 例 / 1 失败(该 1 例位于并发会话在建文件SmallTouchTargetScanTest,
与本节改动无关);lintDebug0 Error / 267 Warning
Changed(构建与签名)
- Release 签名独立化:
keystore.properties指向项目根release-key.jks,
signingConfigs.release.storeFile改用rootProject.file(...)。
已保存的 NAS / 百度凭据不受影响,但换签名后需先卸载旧包再安装 - CI
buildjob 签名步骤改双模式:配置 Secrets 时用正式 keystore 签名,未配置时回退
throwawayci-keystore.jks
NASMusicTV v2.35.0
[v2.35.0] - 2026-09-18
网络音乐多码率:补齐 192 档,播放与下载支持按档位静默降级;同曲多档可共存
设计见
docs/multi-bitrate-playback-download-plan.md(v1.3 可开发性审阅定稿)。
Added
- 192 kbps「高品」档位:此前只有 自动/128/320/无损 四档,用户在「极差」与「较贵」间二选一。
新增QualityTiers.GOOD = 192,设置页、播放器面板、下载面板全部改为遍历单一真相源 - 歌曲行档位徽标(§5.2.3):已下载歌曲在列表行显示实际落盘档位
(无损 / 320k / 192k / 128k)。同曲多档并存时靠它区分,否则用户会误以为重复下载了同一首歌。
数据经DownloadState.Completed.quality传递,覆盖本地曲库 / 搜索结果 / 专辑详情 / 网盘等
全部使用UnifiedSongRow的列表;quality == AUTO时不渲染徽标 backend/network/QualityTiers.kt:档位单一真相源——常量、标签、扩展名映射、降级链。
统一由fallbackChainOf(tier)产出降级链(无损 =999 → 320 → 192 → 128)backend/network/ResolveResult.kt:解析结果封装(url, actualQuality),承载降级信号。
isDowngradedFrom(requested)判定是否发生静默降级(AUTO 档恒为 false)- 单曲音质覆盖(两级模型):
data/prefs/QualityOverrides.kt—— 全局默认档位之外,
可在播放器中为单首歌指定「仅本次播放」的档位,独立 DataStore 文件、500 条 LRU 上限。
设置页新增「清除全部单曲覆盖」入口 - 单曲下载可用码率探测:
backend/download/QualityProbe.kt—— 并发探测 4 档
(async/awaitAll+ 单档 1200ms 超时)。点击下载后:多档可用 → 弹出码率选择面板;
仅一档 → 直接下载不弹窗;零档 → 提示错误不入队 DownloadSongEntity.quality字段 +songId索引:下载索引首次具备档位维度,
同曲多档可共存(无损.flac与320.mp3并存不冲突)- 播放器音质切换器(NowPlaying 控制行)+ 音质选择面板(5 档单选 + 范围二选)
- 设置页两条说明文案:无损档限制说明(「无损仅限支持 FLAC 的曲库;不可用时自动降级并提示」)
与作用范围说明(「本档位仅影响网络音乐;NAS / 本地 / 网盘歌曲按其原始码率播放」),中英双语 QualityBadge档位徽标组件、DownloadQualityPickerDialog/QualityPickerDialog/QualityProbingDialog三个面板- 本地 / NAS 歌曲播放时显示真实码率(只读,不可点击):音质标识改为两态 ——
网络歌曲显示档位标签(♪ 无损/♪ 320k,可点击切换),
本地 / NAS / 下载歌曲显示真实码率(♪ 320 kbps/♪ 无损 1.4M,只读)。
数据来自Song.bitrate(NAS 适配器解析时填充)与Song.resolvedQuality(下载档位回退);
飞牛后端因BITRATE_UNVERIFIED = 0不渲染徽标,不会显示错误的「♪ 0 kbps」
Changed
- 下载文件名体现实际码率:非无损档追加档位后缀(
03 - 南方姑娘 (320).mp3),
无损档靠.flac扩展名区分。后缀取实际命中档位,降级后不会出现「无损的文件名 + 320 的内容」 - 下载扩展名不再靠 URL 猜:
extOf(url, song, quality)改为档位优先。
此前无损直链常无.flac后缀,会被误存成.mp3容器 - 下载链路顺序调整:解析直链先于建立 DOWNLOADING 记录。
原顺序在降级时会残留一条永不完成的孤儿行(key 用请求档、文件落实际档) - 自动下载去重改走档位:
isDownloaded(song, quality)替代dedupeKey粗判,
并新增落库前按实际档二次去重——否则「请求无损→降级 320」在下次运行会重复插入同 key 触发主键冲突 - 播放缓存键含档位:
networkSource:networkId:quality(原为song.id),
修复「切档位后 5 分钟内仍命中旧档直链、音质切换静默失效」 - 已下载歌曲按档位决定播放源:此前只要下载过(任意档位)就永远播本地文件,
导致切档对已下载歌曲完全无效。现在按有效档位查本地:该档已下载 → 播本地;
未下载 → 走网络解析;解析降级且降级档已下载 → 回退本地;解析失败 → 任意已下载档兜底 NetworkMusicService新增resolvePlayUrl(song, quality)与resolvePlayUrlDetailed(song, quality)
两个带默认实现的方法,不支持多码率的源(Jamendo / 百度网盘)无需改动- 版本号
2.34.4 → 2.35.0(versionCode 152 → 153) - 批量下载档位选择不实施:本项目 UI 无批量下载入口(全仓库仅单曲下载
onDownloadSong),该需求属"先新建批量下载 UI"的新功能而非本方案补全断点。
单曲路径的逐曲静默降级已可用,将来批量入口落地时可直接复用
enqueue(song, auto, quality)
Fixed
AppPreferences.setQualityTier()不失效播放缓存:直链是「音源 × 歌曲 × 码率」绑定的
时效性资源,档位一变旧链接语义全失效。现通过回调清空NetworkMusicManager.playUrlCacheSTANDARD档降级链产生重复项:fallbackChainOf(128)返回[128, 128],
白跑一次网络请求。单测QualityTiersTest捕获并修正- 降级链跳过 192 档:无损降级链原为
999 → 320 → 128,与「补齐 192」的决策矛盾 QualityOverrides内存镜像被旧快照回退(测试捕获):init里的
dataStore.data.collect { cache = decode(it) }与 put/remove 的同步写竞争——
连续两次put时第一次 persist 触发的回调可能后到,把更新的内存值回退成旧值。
用户选完「仅本次播放 999」立刻播放可能读到上一次的档位。已移除该 collector
(单进程下所有写入必经本类方法,collector 无必要),put()返回后即刻可读新值
手机端实测修复(用户真机反馈,5 项):
- 音质档位只显示「自动」(既有缺陷,v2.35.0 之前就存在):
SettingActionButton内部硬编码Modifier.fillMaxWidth()(设置页纵向列表样式),
但音质档位行把它放进Row—— 第一个按钮撑满整行,后续 4 个被挤成 0 宽。
修复:该组件新增modifier参数(默认值不变,不影响其余 25 处调用),
音质档位行改为纵向列表(同时解决"横排 5 个「确定」文案易误读"与"手机窄屏溢出") - 码率面板点「确定」没反应:三个叠加原因 —— ① 复用的
SettingActionButton
右侧硬编码「确定」文案(行样式),让每行都像确认按钮;② 点行只切换选中标记、
不触发下载;③enqueueManual在已下载/下载中时静默 return,且
downloadVM.message无人消费 → 确认后零反馈。
修复:新增专用行组件QualityOptionRow(右侧显示「已选/选择」状态)与
QualityDialogButton(主/次按钮视觉区分);enqueueManual返回 Boolean
区分两种结果并各给提示;把downloadVM.message转发到 errorMessage 通道 - 码率面板「下载」按钮消失:
if (selectable.isNotEmpty())使得该曲所有可用档
都已下载时按钮整个不渲染,用户只看到「取消」。修复:始终渲染,
无可选档时置灰禁用 + 补"如何重新下载"指引 - 面板被截断 / 底部按钮不可见:两个对话框的
Column固定宽度、无高度上限、无滚动。
修复:BoxWithConstraints+ 宽度取「560dp 与 视口 92%」较小值 +
heightIn(max = 视口-32dp)+verticalScroll - NowPlaying 看不到音质入口:原加在底部控制按钮行,但该行在固定
width(380.dp)
容器内且无横向滚动,第 8 个控件被裁掉。修复:按需求移入「信息 + 来源」同一行
(♪ 320k),并给该行与按钮行补横向滚动兜底
Tests
- 新增
DownloadDatabaseMigrationTest:在真实 v1 库上验证MIGRATION_1_2——
存量行数不变、songKey未被改写、quality全为 0、业务字段原样保留、
songId索引存在可用、存量行与带档位后缀的新行可共存、唯一约束仍生效、
AUTO 档能命中存量行("零重建迁移、不丢数据"的硬证据) - 新增
QualityProbeTest:用虚拟时间断言探测并发执行
(总耗时 ≈ 单档而非 4 倍)、单档超时不阻塞其他档、整链超时封顶、
仅返回可用档且降序、探测不含 AUTO 档 - 补齐方案 §10.1 列出的全部 13 个测试类(新增 77 例,全量 796 例 / 0 失败):
QualityTiersTest/ResolveResultTest/DownloadKeyTest/DownloadQualityPathTest/
DownloadStateLookupTest/DownloadDatabaseMigrationTest/QualityProbeTest/
PlayUrlCacheKeyTest/QualityOverridesTest/MetingResolveTest/
LocalPlaybackPriorityTest/AutoDownloadDedupeTest/DowngradePersistTest - 新增
QualityBadgeLabelTest(14 例):音质徽标三态 —— 网络歌曲显示档位标签、
本地/NAS 显示真实码率、无数据不渲染(避免出现「♪ 0 kbps」);
含999不显示为999 kbps、bitrate优先于resolvedQuality等边界 - 新增
DownloadConfirmFeedbackTest(6 例):锁定"码率面板确认后必有反馈"契约 ——
入队与被幂等拦截两种结果必须各有文案,不能静默 - 可测性调整:
QualityProbe.probeAvailableQualities与
StreamUrlResolver.resolveDetailed新增可注入dispatcher参数(默认 IO),
使runTest虚拟时间可用;§3.6 的播放源决策从PlayerViewModel抽到
NetworkPlaybackResolver(纯 suspend + lambda 注入);音质徽标文案抽到
ui/components/QualityBadgeLabel.kt纯函数。三处生产行为不变 - 最终全量 814 例 / 0 失败
Database
downloads.dbversion 1 → 2,新增MIGRATION_1_2:
一条ALTER TABLE download_songs ADD COLUMN quality INTEGER NOT NULL DEFAULT 0+
一条CREATE INDEX index_download_songs_songId。零重建迁移,不重建表、不回填、不丢数据- 存量行
quality取列默认值 0(AUTO 档),而 AUTO 档的songKey与旧格式完全相等,
因此升级后已下载歌曲天然被命中,不会重复下载 DownloadDatabase的exportSchema由false改为true,导出
app/schemas/.../DownloadDatabase/{1,2}.json作为迁移测试的权威基线(勿手改 JSON)
NASMusicTV v2.34.4
[v2.34.4] - 2026-09-18
修复:导入歌单 stub 不再误标「NAS」来源,播放时同步/异步补全后标签即时刷新
Fixed
- 导入 stub 来源误标 NAS:
SourceIdentifier.sourceType()对isNetworkSong=false的导入 stub
一律返回NAS——实际 NAS 未连接时显示错误且播放必败。新增IMPORTED枚举(灰色「导入」标签),
在!isNetworkSong → NAS判定前先检查id.startsWith("imported_"),stub 阶段正确标记为「导入」 - 播放 stub 不补全直接失败:
PlayerViewModel.playQueue()的needsResolve跳过 imported stub,
triggerEnrichForStubs异步补全跑在播放之后。现在needsResolve加入 stub 判定,
playQueue()对首曲 stub 同步调用playlistEnricher.enrichSong()(NAS 精确匹配 → 网络源 fallback),
命中后立即持久化到队列/收藏/播放列表并更新sourceType为实际来源(NAS/NETWORK_MUSIC) resolveStreamUrl不处理 stub:同上,新增id.startsWith("imported_")分支,
先同步补全再走正常解析链路- 异步补全后 UI 标签不刷新:
triggerEnrichForStubs只写了 DataStore 持久化,
未更新PlayerManager内存队列——UI 读playerState.queue仍是旧 stub,导致除首曲外后续歌曲
标签停留在「导入」。新增PlayerManager.replaceSongInQueue()原地替换队列中指定 id 的歌曲
(若是当前播放曲也同步更新currentSong),triggerEnrichForStubs补全命中后立即调用刷新 UI triggerEnrichForStubs跳过正在播放的首曲:首曲已由playQueue()同步补全,
异步批量补全从第二曲开始- 英文字符串缺失:歌单导入/上传相关 20 条字符串资源未翻译为英文,lint 报错。全部补齐
Changed
- 版本号
2.34.3 → 2.34.4(versionCode 151 → 152) NasMusicApp新增playlistEnricher字段,MainViewModel/PlayerViewModel改为共享实例PlaylistEnricher.enrichAndPersistEverywhere()返回类型从Boolean改为Song?
(返回补全后的 Song,未命中返回 null),避免调用方需二次调用enrichSong()
[v2.34.3] - 2026-09-18
歌单导入:m3u / txt / json / 网易云歌单一键入库,URL 直链直接使用并校验可达性
本地歌单导入功能全链路落地(设计见
docs/playlist-import-feature-plan.md):
音乐文件列表(.m3u)、纯文本歌单、JSON 歌单(HHX / 通用)与网易云分享链接四种格式,
支持 GBK 编码兜底与 4 KB 嗅探自动识别。歌单内若带 http(s) URL 直链(m3u path hint
或裸 URL 行),直接使用、无需逐首搜索——导入后后台并发 4 路 HEAD 测可达性,
不可达条目打上「URL 失效」标记(24h 判定窗口持久化,重启不重测;局域网 URL 不预判,
交给 ExoPlayer 首次播放判定)。歌曲名(stub)在「播放时 + 手动补全」两种时机向
NAS/网络源匹配补全;播放失败自动复测回退。导入源文件只读流式解析、不落盘。导入入口(2026-09-18 追加):设置→数据「导入歌单」改为**「二维码 + URL 远程上传」弹窗**
(本地 HTTP 服务端口 18084,避开 18080–18083 既有端口段)——电视展示二维码/可点击 URL,
手机扫码或电脑浏览器上传歌单文件,上传即导入。Android TV 无 DocumentsUI,原 SAF 文件选择器
在电视上会抛 ActivityNotFoundException,已整体拆除。
Added
- 解析器:
M3uPlaylistParser(URL path hint 捕获 + 裸 URL 行)、NeteaseCloudPlaylistParser、
JsonPlaylistParser、TextPlaylistParser(Tab/分隔符/编号剥离/注释/BOM/4KB 上限/无意义文件名白名单/GBK 兜底) - 导入编排:
PlaylistImporter(读流不落盘、命名兜底、normalizeKey 去重、25% 步进进度回调、导入历史记录) - 可达性检查:
UrlReachabilityChecker(HEAD + 5s 超时、并发 4、5 分钟内存缓存、局域网不预判、
DNS/超时/重定向环等 7 类结果、seedCache灌入持久化判定) - 补全链路:
PlaylistEnricher(NAS 精确匹配优先 → 网络源 fallback,替换后保留原 streamUrl / 置空交给再解析;
enrichAndPersist/enrichAndPersistEverywhere两档写回);MainViewModel三入口(播放/队列/播放失败)
挂 stub 补全 hook +playbackFailure复测回退,PlayerManager.onPlayerError对imported_HTTP stub 跳过 re-resolve 直接跳曲 - UI:设置→数据「导入歌单」→「二维码 + URL 远程上传」弹窗(
PlaylistImportUploadDialog:
URL + ZXing 二维码 + 可点击 URL + 关闭;PlaylistUploadServer端口 18084,分块累积 + 5MB
上限防 OOM,/上传页 +/api/uploadRAW body 上传)、「最近导入」记录(单「打开」无删除)、
「我的」页歌单卡片「导入」标签 + 「补全」按钮 + 补全进度条 + 「URL 失效」计数,
歌曲行「待补全 / URL 失效」双徽标(UnifiedSongRow.urlStatus)
Fixed
- 上传 body 读取超时:
PlaylistUploadServer/BackupTransferServer原按「read到 -1」
循环读 body——HTTP/1.1 keep-alive 连接上读完 Content-Length 字节后继续read会阻塞至
SO_TIMEOUT(10s)抛SocketTimeoutException,电脑上传 txt 报「导入失败: null」。
改为按 Content-Length 定长分块读取(读完即止,不预分配防谎报 OOM),缺
Content-Length 直接拒绝;异常 message 为 null 时兜底显示异常类名,不再显示 "null"。 - 数据层:
PlaylistImportHistoryItem模型、AppPreferences歌单导入历史 /songReachability
持久化(24h 窗口 + 备份导出恢复联动)、PlaylistImportViewModel(导入/补全编排 + 预热)
Changed
- 版本号
2.34.2 → 2.34.3(versionCode 150 → 151) UrlReachabilityChecker.CacheEntry增加ttlMs字段,支持持久化判定与运行时判定差异化 TTL
Test
backend.playlist.*测试包 73 例全绿(Parser 27 + Importer 15 + UrlReachabilityChecker 15 + Enricher 16)- 「URL 可达性判断后直接使用」的用户决策(2026-09-18)已落实到导入编排与 UI
[v2.34.2] - 2026-09-17
网络音乐播放失败多级降级:链接失效不再连锁跳歌
此前网络歌曲播放失败重试时命中「已过期未到 5 分钟 TTL」的旧缓存,导致连续多首歌曲
解析失败 → 全部静默跳过。现在重试路径强制绕过缓存走完整降级链,并新增同源重搜 + 跨源
替换两级降级,大幅降低连续跳歌概率。降级链路(从轻到重):
- 层级 0:
resolvePlayUrl(forceRefresh=true)— 强制绕过缓存重新解析 + 失败清缓存- 层级 1:同源重搜 —
title+artist关键词重搜,逐条可播校验取替代曲- 层级 2:跨源替换 — 遍历其他已注册源搜索 + 可播校验,替代曲替换队列中对应位置
- 层级 3:全部失败 → 自动跳下一首
防死循环:跨源替换产物的歌曲 id 被记录,若替代曲再次解析失败不再触发跨源,直接跳曲。
Changed
NetworkMusicManager.resolvePlayUrl()新增forceRefresh: Boolean = false参数:
forceRefresh=true 时跳过缓存读 + 解析失败清除缓存条目,修复重试路径命中过期缓存的核心问题PlayerViewModel.resolveStreamUrl()新增forceRefresh参数并透传PlayerViewModel.resolveAndPlayByIndex()解析路径统一传forceRefresh=true
Added
NetworkMusicManager.resolvePlayUrlWithCrossSourceFallback():原源解析失败时按orderedServices顺序遍历其他已注册源,用title+artist重搜并逐条可播校验,返回CrossSourceResult(replacement, playUrl, sourceId)NetworkMusicManager.CrossSourceResult数据类:跨源降级结果载体PlayerViewModel.tryReplaceByReSearch():层级 1+2 降级入口,包含跨源替换防死循环逻辑PlayerViewModel.tryCrossSourceReplace():封装跨源降级调用 + 代数校验PlayerViewModel.lastCrossSourceReplacedId:防死循环字段,记录最近跨源替换产物 id- 字符串资源
cross_source_replace_playing:跨源替换提示「《%1$s》链接失效,已用 %2$s 源版本替换播放」 - 新增
NetworkMusicManagerTest(11 例 / 0 失败):forceRefresh 跳过缓存、失败清缓存、跨源降级顺序、候选可播校验、同源排除、防死循环
Test
- 全量
testDebugUnitTest通过(11 例新增 NetworkMusicManagerTest)
[v2.34.1] - 2026-09-17
沉浸播放页:封面右侧竖排显示歌曲名 / 艺术家
沉浸模式(左半屏大封面 + 右半屏黑底歌词)此前整屏看不到歌曲名和艺术家,切歌后无法确认
"现在放的是哪首"。现在在封面右侧的虚化渐黑区用竖排两列显示:最右一列 = 歌曲名(白色
加粗),其左侧一列 = 艺术家(主题青),两列上对齐。
⚠️ 放这里而不是压在封面上的原因:封面色调不可控,任何颜色的文字都可能撞色;而虚化渐黑区
的底色已被压到 ≥83% 黑,是确定的暗色 → 文字可以固定用亮色。竖排两列只占约 60dp 宽,
刚好落在该暗区内。未实施部分:① 电视实机视觉验收(竖排字距、两列与渐黑区的相对位置、长歌名截断表现
需上机看,按项目约定由用户执行);② 文字不可聚焦(沉浸模式下整个左半封面是"点击退出
沉浸"的 Surface,文字入焦点链会和它抢 D-Pad 焦点)。
Added
ImmersiveCoverHalf新增第 ④ 层:BoxWithConstraints(TopEnd+fillMaxHeight)内一行
Row(verticalAlignment = Top,列间距 12dp):艺术家(FontSize.body()+Primary青)在左、
歌曲名(FontSize.title()+ Bold + 白)在右- 新增
VerticalText:Compose 没有原生竖排(TextStyle无 writing-mode),按码点拆字后
逐字堆一列Text,lineHeight固定为字号 × 1.15 - 字数上限 =
maxHeight / (字号 → dp × 1.15)反推并夹在 2…18,超出末字替换为「…」→ 任何
屏幕高度下都不溢出封面;歌曲名 / 艺术家皆空(电台条目)则整块不渲染 ⚠️ 竖排必须按 Unicode 码点拆,不能按Char:按Char拆会把 emoji / 生僻字的代理对
切成两个乱码;且不能用String.codePoints()(返回IntStream,java.util.stream
是 API 24+,minSdk 22 会NoClassDefFoundError),只能Character.codePointAt+charCount⚠️ 不需要PlatformTextStyle实验性 API:ui-text 1.6.1 的DefaultIncludeFontPadding
已经是 false,默认行高足够紧凑
Test
- 新增
ImmersiveVerticalTextTest(9 例 / 0 失败):竖排拆字按码点(emoji / 生僻字的
代理对不被拆开)、超长截断补「…」且总长不超上限、长度正好等于上限时不补、上限 1、空串 - 全量
testDebugUnitTest556 例 / 0 失败
[v2.34.0] - 2026-09-17
播放统计新增「听歌热力图」——按日期看播放
设置 → 数据管理 → 播放统计 页新增第三个 Tab,以 GitHub 贡献图样式的热力图展示
最近一年(53 周)每天的播放情况,并给出「有听歌的日子 / 最长连续 / 最活跃一天」三项摘要。未实施部分:① 电视实机视觉验收(网格尺寸、色阶对比度需上机看,按项目约定由用户执行);
② 热力图格子暂不可聚焦(纯展示、不进 D-Pad 焦点链),因此无法用遥控器查看某一天的具体
播放次数 —— 若要支持"聚焦显示当日详情",需要给 371 个格子做焦点管理,会与歌手横排的焦点链
互相抢焦点,属独立议题;③ 历史回填上限受play_records500 条约束(尽力而为,不是完整历史)。
Added
数据层:按天播放统计(play_stats_daily)
- 新增 DataStore 键
play_stats_daily,格式{ "yyyy-MM-dd": count };保留最近 400 天
(> 53 周窗口的 371 天),超出滚动清理 ⚠️ 写入点只挂recordPlayWithSong(与月度统计同一次 DataStore edit,原子写),
不挂addPlayRecord—— 后者是播放结束/切换时的另一条路径,两处都加会让每天次数翻倍- 一次性历史回填:首次打开统计页时把
play_records(带 timestamp)聚合进 daily;
幂等靠play_stats_daily_backfilled_v1标记,且仅在 daily 为空时才回填(daily 已有值说明
增量计数早已生效,回填会重复计数)。上限受play_records500 条约束,属"尽力而为"
聚合层:data/stats/PlayHeatmap.kt(新增,纯函数可单测)
- 窗口 = 最近 53 周;列 = 周、行 = 星期(行 0 = 周日);未来日期为
null(留空不绘制) - 颜色分级取非零播放量的四分位而非"相对峰值" —— 峰值远高于日常时,相对峰值会把绝大多数
格子压成同一档,热力图失去信息量 ⚠️ 只用java.util.Calendar,不用java.time:minSdk 22 且未启用 core library desugaring,
LocalDate在 API < 26 上会NoSuchMethodError(编译期和单测都发现不了,只有低版本设备会崩)- 月份标签对齐到"该月第一列";与上一个标签不足 3 列时顺延而非丢弃(窗口开头常出现只剩几天的残月)
UI:ui/screens/stats/PlayHeatmapChart.kt(新增)
- 格子边长按可用宽度自适应,夹在 3…16dp:电视大格、手机小格,都不需要横向滚动
- 网格用单个 Canvas 一次性绘制(371 格),低端 TV 上比 371 个 Compose Box 更稳
- 纯展示、不入 D-Pad 焦点链,不打乱 返回 → Tab 的焦点顺序
PlayStatsScreen的 Tab 由 2 个变 3 个(本月 / 累计 / 热力图);showAllTime: Boolean
改为StatsTab枚举⚠️ 热力图独立成 Tab 而非追加在页面下方:本页 Column 不可滚动,而 TV 上无焦点的滚动容器
无法用遥控器驱动 → 堆在下方的内容会被直接裁掉
文案:新增 12 条 pstats_heatmap_* / pstats_weekday_*(中英双语);
pstats_entry_desc 补上"听歌热力图"
Test
- 新增
PlayHeatmapBuilderTest(18 例 / 0 失败):网格 53×7 与星期对齐、未来日期留空、
窗口边界、窗口外数据剔除、单日定位、跨月跨年、最长连续、峰值日(并列取最早)、四分位分级、
月份标签(13 个且列间距 ≥ 3) ⚠️ 其中「写入端PlayStatsRepository.currentDay与读取端 dateKey 同口径」这条最关键:
口径不一致会导致"统计写得进去、热力图读不出来、整张图全空",而症状与"没有数据"一模一样
NASMusicTV v2.33.0
[v2.33.0] - 2026-09-17
Android Auto 车机支持(阶段 1:可发现 + 可浏览 + 可播放)
开发方案见
docs/android-auto-plan.md。本轮落地阶段 1 的全部必要代码,
使本应用能在 Android Auto 车机(手机映射投屏模式)中被发现、浏览并播放。不改 minSdk、不加 flavor、不新增依赖 —— Android Auto 复用现有 APK,
与 AAOS(车机内嵌 Android)是两条独立路线。未实施部分(详见方案 §七 与 §十一):① DHU / 真车端到端验收(阶段 1 的验收动作,
也是 2026-09-07 那次审查的遗留建议);② 艺人/专辑节点 + NAS 短期缓存
(阶段 2.3 的剩余部分;歌单已接入。⚠️ 存在待决策的设计缺口:mediaId 结构已定义,
但根菜单固定 4 项,这两个节点从根菜单不可达,需先决定入口方式);③ 搜索与语音、
onPlaybackResumption(阶段 3,其中语音搜索的正确实现机制已在本轮探明,见Notes);
④ 强调色定制(阶段 4.2,已核实确实需要:主题未设colorAccent)、包验证收紧为签名级(4.3)。
阶段 2.1 / 2.2 / 2.4 / 2.5 / 4.1 已随本轮一并完成。
Added
Android Auto 可发现性(缺任一项车机完全看不到本应用)
- 新增
app/src/main/res/xml/automotive_app_desc.xml,内容为<automotiveApp><uses name="media"/></automotiveApp> AndroidManifest.xml的<application>新增com.google.android.gms.car.applicationmeta-data 指向该文件PlaybackService的intent-filter补注册android.media.browse.MediaBrowserService(原先只有androidx.media3.session.MediaLibraryService)。两个 action 必须同时存在:缺前者 Media3 客户端找不到,缺后者平台MediaBrowser/ Android Auto 找不到
媒体浏览树(player/MediaLibraryTree.kt 重写)
- 根菜单固定 4 项可浏览节点:当前播放 / 离线下载 / 收藏 / 歌单,按 root hints 动态裁剪
- 前 3 项不依赖家庭内网 —— 开车时手机在移动数据上、NAS 内网地址不可达,必须保证车机端始终有内容可播
- 歌单下钻:
pl→pl/{id}→ 歌曲 - 叶子节点只带
mediaId、不设 URI(网络歌曲streamUrl按设计不持久化;NAS 流地址带 token 会过期),URI 由播放入口统一解析
根菜单 tab 图标(阶段 2.5)
- 新增 4 个单色白矢量图标(
res/drawable/,24dp):ic_auto_queue(播放三角)/ic_auto_download(下箭头)/ic_auto_favorite(五角星)/ic_auto_playlist(三横线) - 官方规范原文要求:"Monochrome (preferably white) icons for each tab item",此前 4 项根菜单完全没有图标,车机上是占位样式
⚠️ 只设artworkUri指向矢量 XML 是不可靠的:BitmapFactory无法解码 VectorDrawable,而 Media3 到 Android Auto 的iconUri通路要求消费方能解码该 URI。故采用**「矢量图源 + 运行时光栅化」:MediaLibraryTree.rasterizeIcon()把矢量渲染成 256×256 PNG,经setArtworkData()走确定性最高的iconBitmap通路**(LegacyConversions.java:329);同时仍设artworkUri(android.resource://)作次选(LegacyConversions.java:357)- 结果按 resId 缓存(4 个图标只渲染一次);渲染失败降级为「无图标」,不影响内容树加载
提供方图标 attribution icon(阶段 4.1)
- 新增
res/drawable/ic_car_attribution.xml(24dp 单色纯白),并在<application>下声明androidx.car.app.TintableAttributionIconmeta-data - 官方原文(
training/cars/media/configure-manifest「定义提供方图标」):用于媒体卡片等「媒体内容优先」的位置,必须是单色、推荐矢量 - 图形复用品牌标记(与
mipmap-*/ic_launcher的 adaptive icon foreground 同源:播放三角 + 左右两道声波),但没有重画路径 —— 用<group>等比放大(scale 1.75/translate(-40.5, -40.5),推导见docs/android-auto-plan.md§七 4.1),避免手写坐标偏差。放大是必需的:adaptive icon 前景只占 44% 宽度是为了启动器安全区裁剪,本图标是独立图标、不经裁剪,直接沿用会明显偏小 ⚠️ 推导时踩到一个坑:包围盒上界是 34(三角顶点),不是 Q 控制点的 y=32 —— 控制点不在曲线上。按后者推导会算出translateY = −38.75,使图标整体偏低 1.75 单位;靠离线渲染脚本打印变换后包围盒(y∈[20.75,90.75],中心 55.75 ≠ 54)才发现,修正为 −40.5 后实测x∈[12,96] y∈[19,89]两轴精确居中。矢量路径的包围盒不能靠读控制点手推。⚠️ 两个易误判点:① meta-data 名带androidx.car.app前缀,但不需要引入 Car App Library 依赖(它只是平台读取的字符串键;那个库是给导航/POI 类 AAOS 模板应用用的,与 Media3 媒体应用是两条路)——「不新增依赖」的路线前提依然成立;② 名字里的Tintable表示平台会着色,故填纯白而非品牌青绿#2DD4BF
搜索与语音(阶段 3)
MediaLibraryTree新增suspend fun search(query)—— 双源合并:网络音乐(Meting,公网,始终尝试)+ NAS 后端(内网,searchSongs,尽力而为)。按song.id去重(LinkedHashMap保序,网络结果排前面),上限MAX_SEARCH_RESULTS = 50;复用既有的songToItem(),故结果天然满足LibraryResult.ofItemList对 item 的三项硬要求(mediaId非空 +isBrowsable/isPlayable显式设置)PlaybackService新增onSearch—— 按 Media3 的两步流程,本方法只回结果码,并通过notifySearchResultChanged(browser, query, itemCount, params)通知结果数量。⚠️ 只回结果码而不通知数量,车机端不会来取结果(表现为「搜了但列表空」)PlaybackService新增onGetSearchResult—— 返回真正的结果列表。⚠️ 不假设onSearch已先被调用:javadoc 明确 query「may not」先经onSearch(走MediaBrowserCompat#search时不会),故本方法能独立搜索,60s TTL 的searchCache只是省一次重复搜索的优化、不是正确性依赖PlaybackService.onSetMediaItems新增语音搜索分支(最先拦截)—— Google 助理「播放 XXX」不走onPlayFromSearch(Media3 根本没这个回调),而是经MediaSessionLegacyStub转成onSetMediaItems(mediaId="", searchQuery=XXX)。判定条件 =「mediaId.isBlank()且searchQuery非空」;startIndex/startPositionMs由C.INDEX_UNSET/C.TIME_UNSET归一为0/0LAndroidManifest.xml:PlaybackService的 intent-filter 补注册android.media.action.MEDIA_PLAY_FROM_SEARCH;同时移除<application>上的tools:ignore="MissingIntentFilterForMediaSearch"与该文件已无使用方的xmlns:tools声明- 新增
SEARCH_TIMEOUT_MS = 10_000L(比浏览的BROWSE_TIMEOUT_MS宽 —— 要跨公网 + 内网两源);NAS 侧失败用AppLog.d静默跳过(内网不可达在车机场景下是常态,不是异常)
播放入口(PlaybackService)
- 新增
onAddMediaItems覆写(默认实现在 item 缺LocalConfiguration时会抛UnsupportedOperationException) - 新增
onSetMediaItems覆写 —— 覆写后它成为所有点歌路径的统一入口(legacy 的playFromMediaId/playFromUri等都会汇聚过来) - URI 三级解析:已有 URI →
Song.streamUrl→NetworkMusicManager.resolvePlayUrl()(走公网,车机场景可用) - 新增
player/BrowseCache.kt:mediaId → Song的 LRU 缓存(2000 条),供播放入口在 Media3 会话线程上同步读取,避免回调里做网络 IO - 新增
onConnect包验证:放行系统进程 / AAOS 控制器 / Android Auto 控制器 / 本应用 / Google 助理(手机com.google.android.googlequicksearchbox与 AAOScom.google.android.carassistant包名不同,分别放行);DEBUG 构建全放行以免调试期"莫名连不上"
PlayerManager 新增 API
syncQueueFromExternal(songs, startIndex)—— 只同步 T3 状态镜像、不触碰 player。因为 Media3 会用onSetMediaItems的返回值自行调player.setMediaItems(),此处再调playQueue()会重复设置updateStreamUrl(index, url)—— 回写队列中某条的streamUrl,不动currentIndex/currentSongreplayAt(index)—— URL 异步解析完成后原地续播builtinStreamUrlResolver—— 无 UI 依赖的 streamUrl 解析器,由PlaybackService注册;未注册时行为与改动前完全一致
Fixed
⚠️ 搜索去重用了 API 24+ 的方法,会在目标电视(Android 5.1.1)上崩溃:首版MediaLibraryTree.search()写成merged.putIfAbsent(...),编译通过、单测也过,但lintDebug报 2 条 error ——Call requires API level 24 (current min is 22): java.util.HashMap#putIfAbsent [NewApi]。LinkedHashMap的putIfAbsent解析到HashMap#putIfAbsent(API 24 才加入)→ API 22 上运行到搜索即NoSuchMethodError崩溃。改用 Kotlin stdlib 的MutableMap.getOrPut(纯 Kotlin,无 API 版本限制,语义一致)。该问题只有 lint 能抓 ——assembleDebug/Release与单测都发现不了。已顺带全项目排查putIfAbsent/computeIfAbsent/removeIf/Map.merge,仅此一处- A-13(静默播不出)网络歌曲在无 UI 场景下播不出来:
onNeedResolveStreamUrl的实现注册在MainViewModel(MainViewModel.kt:790),绑在 UI 生命周期上。而 Android Auto / Wear OS / 蓝牙唤起等场景下MainActivity可能从未启动 → 回调为 null → 队列里的网络歌曲静默失败。修复:新增builtinStreamUrlResolver优先接管(PlaybackService侧解析成功后回写队列并续播),解析失败才回落 UI 侧回调。4 处触发点(自动过渡 /onPlayerError重试 /syncAndPlayCurrent/transitionToIndex)统一收口到requestStreamUrlResolution() - A-14 浏览树叶子节点缺 URI:旧
MediaLibraryTree.findInQueue()未调用setUri()—— 2026-09-07 的 P0-9 只修了getQueueItems()。此前未暴露是因为onAddMediaItems还没被覆写(默认实现只在「全部 item 都带 URI」时原样返回)。修复:树里统一不设 URI,改由播放入口解析
Changed
- 移除
onGetChildren的伪分页逻辑:Android Auto / AAOS 官方明确不支持分页,并建议不要依赖page/pageSize。旧实现据此切片会导致列表被静默截断。改为一次性返回(上限 500 条)+ 异步(SettableFuture+withTimeoutOrNull),超时/异常返回空列表而非让车机端一直转圈 PlayerManager.buildMediaItem由private改internal,供PlaybackService构造可播放 item- root hints 常量改用 Media3 的别名:官方文档给的是
androidx.media.utils.MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT,但androidx.media:media:1.6.0在本项目只是media3-session的 runtime scope 传递依赖,compile classpath 上不可见(实测编译报Unresolved reference 'utils')。改用androidx.media3.session.MediaConstants.EXTRAS_KEY_ROOT_CHILDREN_LIMIT—— 源码级确认两者字面量完全相同(media3-session-1.2.1的MediaConstants.java:397-398就是直接赋值自前者),故不新增依赖 ..._SUPPORTED_FLAGS有意不设置:其默认值即FLAG_BROWSABLE,而本应用根菜单 4 项全部可浏览,显式设置是 no-op
Notes
新增 lint 抑制→ 抑制已移除:automotive_app_desc一落地就触发MissingIntentFilterForMediaSearch(要求注册android.media.action.MEDIA_PLAY_FROM_SEARCH)。阶段 1 时有意暂不声明该 intent-filter、加tools:ignore抑制,理由是「声明了也无法响应 = 静默失效的语音搜索」。阶段 3 落地后,intent-filter 与抑制同步处理完毕(补声明 + 移除抑制)。正确机制(media3-session-1.2.1源码级):Media3 的MediaSession.Callback没有onPlayFromSearch(javap实测 11 个 default 方法中无搜索相关),该 intent 经MediaSessionLegacyStub.onPlayFromSearch(:395)→handleMediaRequest(:811-817)→onSetMediaItems(controller, [MediaItem(mediaId="", requestMetadata.searchQuery=query)], INDEX_UNSET, TIME_UNSET)(构造见:947-961;MediaItem.DEFAULT_MEDIA_ID的字面值""见MediaItem.java:2196)- 实施期新发现(方案文档未预见):① 搜不到时必须让 future 失败,不能返回空列表 —— 返回空列表会被 Media3 拿去调
player.setMediaItems(emptyList()),清空播放队列、打断正在听的歌;而让 future 失败时 legacy 路径的onFailure明确写着 "Do nothing, the session is free to ignore these requests"(:843-846),当前播放完全不受影响。②LibraryResult.ofItemList内部verifyMediaItem(LibraryResult.java:257-261)要求 item 的isBrowsable/isPlayable显式设置,缺了直接抛异常 —— 复用的songToItem本就三项齐备,故未另写构造逻辑 - 实施期三处编译/构建问题(均已修复):①
androidx.media.utils.MediaConstantscompile 期不可见(见上);②ExoPlayer.setMediaItem(MediaItem, long)第二个参数是起始播放位置(ms)不是索引,replayAt原写法传 Int 直接编译失败 → 改replaceMediaItem+seekTo;③ 上述 lint error - 技术细节见
docs/technical-overview.md§10.157
验证
- 四任务合并一次运行(
assembleDebug assembleRelease testDebugUnitTest lintDebug)BUILD SUCCESSFUL in 15m 48s(107 个任务:27 executed / 80 up-to-date);assembleRelease内含minifyReleaseWithR8+lintVitalRelease+optimizeReleaseResources+packageRelease testDebugUnitTest529 例 / 0 失败 / 0 错误(57 个结果 XML;基线 518/56,净增 11 例 =SearchMergeTest)lintDebug0 Error / 254 Warning(基线 0/257;净减 3 条,来源见下)。阶段 3 新增代码与阶段 4.1 的 1 个 drawable + 1 个 meta-data 均未带来新告警;且 lint 在阶段 3 实施期抓出 2 条真实 error(putIfAbsent的 API 24+ 问题,见Fixed),修完后归零⚠️ 构建环境提示(重要):拒绝访问/AccessDeniedException类报错有两种成因,靠 stderr 区分 ——现象 判定依据 处置 沙箱拦截 Gradle 删除/写入自身产物 stderr 有 [sandbox] 命令被沙箱拦截关闭沙箱(提权)重跑 真·文件锁 stderr 无任何 sandbox 字样 ./gradlew.bat --stop→ 删掉出问题的中间产物目录 → 重跑本轮两种都实际遇到(一次有 sandbox 字样;另一次提权后 sandbox命中数为 0,却仍在project_dex_archive\...\*.dex上报AccessDeniedException,实测该目录可写、文件非只读 → 属构建期瞬时锁)。详见docs/technical-overview.md§10.157- 改动文件在 lint 报告中的命中:`...
NASMusicTV v2.32.6
[v2.32.6] - 2026-09-16
2026-09-16 审查报告(
docs/code-review-2026-09-16.md)修复落地:共 25 项(P0×2 / P1×7 / P2×11 / P3×5)。
P1-3 经产品裁定为有意设计、不修;P1-9 经核验为误报、撤回——两条保留编号留档,防止后续轮次重复上报。
技术细节见docs/technical-overview.md§10.155。本批主线是跨链路一致性:「主链路修好了、旁路链路漏了」(认证头、空扫描保护、SAF 增量、去重语义都只在主链路上修过)。
Fixed
跨链路一致性
- P0-1(阻断)下载链路未注入认证头:
SongDownloadManager的下载 client 未走BackendAuthHeaders,静默重登后下载持续 401。修复:补forHost(host)拦截器,host 精确匹配使令牌不随 302 泄漏到第三方域 - P0-2 孤儿恢复分支永不命中:下载中断后
entity.audioPath为 null 时无法定位已落盘文件。修复:新增recoverFinalPathOrNull(),按 artist/album/title 反推最终路径(含(2..10)去重序号) - P1-2 下载完成/失败通知不可见:
DownloadViewModel.message存在但全仓库无 UI 消费方(唯一被消费的是MainActivity的viewModel.errorMessage)。修复:改走NasMusicApp.downloadNotifyMessage→MainViewModel.showError - P1-6 SAF 增量判定失效:
shouldOverwrite走 File 路径,SAF 下恒判为需覆盖。修复:按currentRoot分支,SAF 用resolveChildDoc取长度比对;失效的targetFile()删除 - P2-2 USB 扫描前缀不匹配:
devicePath与file://$devicePath两种形态只匹配其一,过滤恒为 no-op。修复:同时匹配两种形态 - P2-4 USB 广播被 scheme 过滤:单个
IntentFilter的filescheme 约束误伤了MEDIA_*之外的广播。修复:拆成 media/usb 两个 filter
并发与队列正确性
- P1-5
cancelAll重启 loop 致新旧 loop 并存:cancelAll末尾startLoop()在旧 loop 仍阻塞于 OkHttp socket 读时立即重启,两个 loop 并存破坏串行队列保证。修复:call.cancel() + cancelRequested 标志 + drain 队列,loop 常驻不重启 - P1-7 T3 三元组不同帧发布:
queue/currentIndex/currentSong未在同一次_playerState.update写入。修复:REPEAT_ONE 回卷/末首与onPlaybackEnded的 REPEAT_ALL 均同帧发布 - P2-6 SmartRadio 陈旧任务回写:上一轮生成任务未取消时会覆盖新结果。修复:以
currentCoroutineContext()[Job].isActive为唯一准入判据(锁内二次确认) - P2-8 飞牛全量拉取无单飞:并发调用各自全量拉取。修复:新增
allTracksRefreshMutex,加锁后锁内重查缓存(否则单飞退化为串行 N 次全量拉取) - P2-9
/api/search阻塞 worker:NanoHTTPD 每请求一线程,慢搜索会占满 worker。修复:Semaphore(2)+tryAcquire失败直接 503 快速失败不排队 + 慢查询耗时日志
静默截断与数据完整性
- P2-7(数据丢失)飞牛分页静默截断:末页判据为
items.size() < size,服务端某页因限流/过滤少发几条即被误判为末页,后续页静默丢失且无任何日志(收藏/全量搜索少歌)。修复:优先按data.total判定(pagesFetched * size >= total),total缺失才回退旧判据;新增fetchPageEnvelope()取整个data信封 - P2-11 模型上传缺完整性校验:
ModelTransferServer.handleUpload落盘后不校验。修复:补 SHA-256 比对EXPECTED_SHA256(private → internal);multipart header 解析加 8KB 总量 + 2KB 单行上限防内存放大 - P3-4 Room schema 未留档:
exportSchema = true但从未配room.schemaLocation——Room 只打警告且一个 schema 都不导出。修复:补ksp { arg("room.schemaLocation", …) },落盘app/schemas/…LocalMusicDatabase/3.json(version 3 / 17 字段 + 1 索引),需随代码入库
边界与语义修正
- P2-10 移除队列末尾正在播放项会跳歌:移除末尾项使 ExoPlayer 进
STATE_ENDED,被onPlaybackEnded的 REPEAT_ALL 误判成「到队尾」而seekTo(0),跳到不相干的歌。修复:抽纯函数computeQueueRemoval()+ 显式seekTo对齐 - P3-5 天气电台 OWM→WMO 映射错乱:
mapOpenWeatherMapCode返回的 20/50/60/70 落不进任何WeatherMood区间,导致 OpenWeatherMap 路径的天气电台一律回退 CLOUDY。修复:改回真 WMO 码 45/51/61/71;describeWeatherCode对照 WMO 表重写 - P3-2 Demucs 逐帧边界检查恒不命中:三个循环上界已保证(约 4200 万次/4min 死分支)。修复:删除,注释写明调用方须自行保证
gi < totalSamples - P3-3 封面缓存实为随机淘汰:
ConcurrentHashMap.keys.take(n)迭代序与插入序无关。修复:新增writtenAt时间表,按写入时间排序淘汰 - P3-1 就地锁死
saveOriginalFile必须保持 copy 语义的约定注释 - P2-1 进度回调按
PROGRESS_STEP节流;P2-5_downloadStates统一.update{};P1-1fullScan空扫描保留旧索引并loadFromCache();P1-4 空间预估* 1024L(SongDownloadManager/AutoDownloadController两处);P1-8/api/queue/add加 try/catch + title 非空 + URI scheme 白名单
Added
app/schemas/com.nasmusic.tv.backend.local.db.LocalMusicDatabase/3.json— Room schema 留档产物(P3-4 的副产物),需随代码入库QueueRemovalTest(6 例)— 覆盖computeQueueRemoval全部边界,含「移除末尾当前项」这一原缺陷场景
Docs
AGENTS.md:更正「单测在本机跑不起来」的过时结论(实测testDebugUnitTest可正常运行);补充「assembleDebug不编译 test 源码」docs/technical-overview.md§10.155 新增本节记录;§10.146 / §10.147 内三处「本机无法跑单测」加日期化更正标注(保留历史原貌,不改写)review-report-audit技能同步更正同一处过时结论
说明
- 过程中发现:第一批修复(P0/P1/P2-1..6)里藏着一处编译错误——
SongDownloadManager.kt:530把缺else的if当表达式用。这说明该批修复当时未经任何编译验证。教训:file:line存在 ≠ 代码能编译,「已修」的充分性必须由编译兜底。 - 环境结论勘误:开工时依据的「本机单测不可用(worker JVM 一启动即死)」已过时并被实测推翻——全量 518 例 0 失败。根因推断为卡死的 Gradle 守护进程持锁;再遇 worker 秒死应先
./gradlew.bat --stop释放锁。 - 验证:
assembleDebug/assembleRelease均 BUILD SUCCESSFUL(5m31s / 9m30s,后者含 R8 +lintVitalRelease);lintDebug0 Error / 257 Warning(基线 256,差值为依赖版本类网络告警波动);testDebugUnitTest518 例 / 0 失败 / 0 错误(512 基线 + 新增 6 例)。产物NASMusicTV-release-v2-32-6.apk(22.9MB),output-metadata.json核对 versionCode 145 / versionName 2.32.6。- 注:本地
keystore.properties的storeFile刻意指向debug.keystore(androiddebugkey),使本地 release 与 debug 同签名、install -r可覆盖已装 debug 版;根目录release-key.jks未被本地构建使用。此为有意设计,非缺陷。
- 注:本地
- 版本:v2.32.5 → v2.32.6(versionCode 144 → 145)
NASMusicTV v2.32.5
[v2.32.5] - 2026-09-15
飞牛批次(v2.32.4)审查后修复:1 项阻断(播放解析链) + 1 项一致性(令牌刷新) + 2 项健壮性;测试总量 25 → 41 例(
FeiniuUrlTest+4,新增BackendAuthHeadersTest6 例、BackendHostOfUrlTest6 例)。修复清单详见
docs/feiniu-backend-improvement-plan.md§14;独立复核记录(harness 复跑 / 编译 / 边界实测)见对应提交说明。
Fixed
- F-1(阻断)飞牛歌曲播放解析失败:适配器全链路未填充
Song.streamUrl,而全 app 的 NAS 播放解析唯一出口是PlayerViewModel.resolveStreamUrl→adapter.getSongsByIds(...).streamUrl(对照:Jellyfin / Navidrome / Subsonic 均在解析期填充)。修复:parseTrack按FeiniuUrl.streamUrl(apiBase, guid)填充。此前点播 / 自动切歌 / 恢复队列全部在解析环节失败(A5 无法通过) - F-2 静默重登后播放 / 封面持续 401:
BackendAuthHeaders由快照改 provider(每次请求实时读取adapter.streamHeaders),重登换新令牌即时生效;FeiniuAdapter.userToken加@Volatile - F-3 IPv6 地址 host 匹配错位:
BackendRegistry.hostOf提取为顶层hostOfUrl并对 IPv6 字面量剥离方括号(java.net.URI.getHost()返回[2001:db8::1]、OkHttpurl.host为2001:db8::1,不剥离则认证头永不注入) - F-4 静默重登覆盖不对称:
getSongsByIds(元数据)/getLyrics/getSongTechnicalInfo/toggleFavorite/ 歌单曲目数补齐均纳入withAuthRetry;新增互斥 + 令牌代数去重(并发场景只真正重登一次) FeiniuUrl.normalize折叠重复斜杠(http://host//music);logout对齐参考项目改为 POST 空 body(无 Content-Type);clearSessionState清loginUsername;deviceId()加@Synchronized;allTracksCache*加@Volatile- CI 首跑暴露的 L3 遗留语义 bug(通知栏「切换播放模式」从未生效):
MutableSharedFlow(replay=0, extraBufferCapacity=0)在有订阅者时tryEmit必失败(投递需挂起)、无订阅者时反而返回 true——requestPlayModeToggle用它判断「有无 UI 订阅」完全颠倒。修复:加extraBufferCapacity = 1(replay 仍 0,不滞留)+subscriptionCount门控恢复返回值语义;PlayModeToggleEventTest改用真实调度器重写(原runTest虚拟调度器下该语义不可测)
Tests
FeiniuUrlTest25 → 29 例(补:无 scheme 带端口、大写 scheme、重复斜杠、hostOfIPv6 剥括号)- 新增
BackendAuthHeadersTest(provider 实时性 / 大小写匹配 / 空 host / 清空 / 异常降级,6 例) - 新增
BackendHostOfUrlTest(含 IPv6 剥括号,6 例)
验证
- 独立 JVM harness 复跑:
FeiniuUrlTestOK (29 tests)、BackendAuthHeadersTestOK (6 tests)(新增logs_temp/verify_auth_headers/harness,与 verify_feiniu_url 同构) assembleDebug/assembleRelease(含 R8)/compileDebugUnitTestKotlinBUILD SUCCESSFUL;lintDebug0 Error(257 Warning);release dex 冒烟确认三个类与新方法(hostOfUrl/fetchLyricsRaw/doToggleFavorite等)均未被 R8 收缩BackendHostOfUrlTest(6 例,含 IPv6 剥括号)随 CI 的testDebugUnitTest执行(源码位于 BackendRegistry.kt,无法独立抽取运行;本机已通过编译验证)- 真机动态项(A5 / A6 / A14)仍需飞牛真机验收
[v2.32.4] - 2026-09-14
飞牛音乐(fnOS)后端对接重写。以可运行的飞牛 TV 客户端
fn-music-tv(github.com/QiaoKes/fn-music-tv)为权威依据,替换原先基于第三方逆向文章猜测的实现。开发计划与评审记录见docs/feiniu-backend-improvement-plan.md(含 §3 缺陷对照表 D1–D22、§10 评审记录)。
⚠️ 旧实现与真实协议在认证方式、分页参数、端点路径、ID 语义四个层面均不一致,按旧代码几乎必然连不上或全量 401/404。本次共修正 22 项缺陷,其中 5 项为阻断级(D1 认证头 / D2 令牌字段 / D9 流地址 / D21 播放链路注入 / D22 封面链路注入)。
⚠️ 本机testDebugUnitTest无法运行(测试 worker 启动即死),Gradle 侧未跑过任何测试。验证手段:assembleDebug+assembleRelease+lintDebug+compileDebugUnitTestKotlin均通过;另用独立 JVM harness(logs_temp/verify_feiniu_url/run.py,绕过 Gradle)把新增的FeiniuUrlTest跑出 OK (25 tests),并做反向对照确认 harness 能检出错误(注入旧错误认知后 9/25 失败)。R8 专项(AGENTS.md 记载 v2.5.1 曾因 Gson 类型擦除 + R8 崩溃):本次未新增任何 Gson 模型类,解析一律用手写
JsonObject取值,不引入新的反射反序列化面;新增的两个类都在-keep class com.nasmusic.tv.backend.**覆盖范围内。已对NASMusicTV-release-v2-32-4.apk的classes.dex做冒烟检查,确认BackendAuthHeaders/FeiniuUrl/FeiniuAdapter三个类与music/api/v1、Authorization、track/stream、static/cover、lyric/list、favorite-track/create、user/password-login等协议常量均未被 R8 收缩。动态行为仍需飞牛真机按文档 §5.2 的 A1–A14 验收。
Fixed
- D1 认证头(阻断): 旧实现发
Cookie: music-token=$token,真实协议是Authorization: <userToken>(原始值,无Bearer前缀)。旧写法导致所有已认证请求 401 - D2 登录令牌字段(阻断): 登录响应取
token,真实字段是data.userToken,旧写法永远取不到令牌 - D9 播放流地址(阻断):
track/{id}/stream→track/stream?guid=<guid>(guid 是查询参数,不是路径段) - D21 播放链路认证注入(阻断,架构缺口):
BackendAdapter.streamHeaders此前定义了但全仓库无消费方——Jellyfin/Navidrome 把凭据拼在 URL 上,所以历史上没暴露。新增BackendAuthHeaders单例 +BaiduHttpDataSourceFactory拦截器注入,飞牛播放不再 401 - D22 封面链路认证注入(阻断,架构缺口): Coil 的 ImageLoader 共用同一 OkHttp 客户端,同样无后端认证头,封面全部加载失败。复用同一拦截器,一次改动覆盖播放 + 封面两条链路
- D3 分页参数:
limit→size(服务端忽略limit,旧写法永远只拿默认 50 条) - D4/D5 专辑/歌手列表: 旧实现用
track/album-detail/list、track/artist-detail/list顶替(后者还靠曲目反推去重)→ 改用真实的album/list、artist/list - D6 歌单曲目:
playlist/{id}/songs→track/playlist-detail/list?playlistGUID= - D7 收藏: 端点
favorite/*→favorite-track/create|delete|list;且切换语义由「先 add 失败再 remove」(无法真正取消收藏)改为按入参isCurrentlyFavorite决定方向 - D8 歌词:
track/{id}/lyrics→lyric/list?trackGUID=,支持preferred/isLRC/offset - D10 封面:
track/{id}/cover→static/cover?coverId=<id>&size=(飞牛按 coverId 取图,不是按曲目 ID) - D11 参数名:
albumId/artistId→albumGUID/artistGUID - D13 时长单位: 移除
duration > 100000 ? ms : sec*1000的猜测放大,服务端duration已是毫秒 - D14 响应解析: 统一按信封
{code, msg, data}解析(旧实现假设data.list或data数组);data == null视为成功空响应(收藏增删场景);错误码99999/120001未认证、120002账号禁用、100005不存在 - D15 连接测试: 未登录时改为探测免认证的
sys/config,不再误报「连接失败」 - D18 搜索: 飞牛没有搜索端点(参考项目全文无 search),改为客户端本地过滤(全量曲目快照 + 5 分钟缓存,按标题/艺术家/专辑匹配)
- D19 批量查询:
track/list?ids=→track/metadata?guid=(并发 4;ID 超过 40 个时改为全量拉取后过滤,避免请求风暴) - D20 默认端口: 连接页飞牛默认端口 80 → 5666(fnOS 音乐服务端口),同步更新
strings.xml提示文案
Added
FeiniuUrl:地址归一化(补 scheme / 默认端口 5666·5667 / 推导/music/api/v1/)+ 端点拼装。有意偏离参考项目的「显式 http 无端口补 80」规则,统一补 5666/5667——本适配器是飞牛专用,填http://192.168.1.100指的一定是音乐服务BackendAuthHeaders:跨进程单例的后端认证头,供播放与封面链路消费。只在 host 精确匹配时注入,令牌不会随 302 泄漏到 CDN / 第三方域名;BackendRegistry在连接成功/断开时同步更新与清空- 版本信息
sys/config→VersionInfo.Runtime(关于页显示飞牛真实版本号与 mediasrv 版本) - 技术信息
track/metadata→SongTechnicalInfo(codec / container / duration)。⚠️ 飞牛audioSpec不含采样率与声道数,填 0;bitrate同样填 0 —— 其单位(bps / kbps)在参考项目里只有 DTO 声明、全项目零使用,契约文档亦未说明,而SongInfoPanel会直接渲染成 "N kbps",猜错即显示 "320000 kbps"。宁缺勿错,UI 显示 "—",真机确认单位后一行即可改回(见文档 §12 Q1) - deviceId 持久化到 SharedPreferences(旧实现每次连接都生成新 UUID,导致服务端设备列表膨胀)
- 令牌失效(401 / code 99999·120001)时静默重登一次再重试
FeiniuUrlTest.kt(纯 JVM,25 用例):地址归一化 / 端点拼装的回归网,覆盖默认端口 5666·5667、显式端口保留、路径幂等(不拼出/music/api/v1/music/api/v1/)、凭据与非 http 协议拒绝、IPv6、guid查询参数形态等。覆盖旧实现真实踩过的坑:默认端口 80、流地址用路径段而非查询参数、封面按曲目 ID 而非coverId
Changed
- 封面主路径明确为
Song.coverUrl+getCoverUrlCandidates(经核查getCoverUrl(songId)在 UI 层零调用,其实现降级为只查内存缓存、不发起网络请求,避免非 suspend 方法在未知线程上做 IO) BackendRegistry构造新增可选appContext(Application 级,不泄漏),由NasMusicApp传入- 飞牛适配器不再调用
EncodingUtils.fixEncoding():该函数会无条件剥掉结尾的?("Why?" → "Why"),而 fnOS 返回正常 UTF-8、参考项目fn-music-tv不做这层处理,用在飞牛上是纯损失。已移除原先的 7 处调用并改为直接str(...)(见文档 §12 Q4)
遗留 / 未做(有意)
- FNID 远程连接与中继模式(relay):依赖 Release 构建期注入的签名密钥与外网 FNID 解析,本仓库无此配置,风险面过大
- 安全码(access code):
x-access-code/x-access-source为可选能力,飞牛未开启时无影响,已预留协议位置但未做 UI - 漫游(
track/roam-*):需维护漫游会话状态,getRandomSongs改为按总数随机取页实现
[v2.32.3] - 2026-09-14
代码质量修复批次(基于 code-review-full-report-2026-09-13.md 审阅落地,实施记录综合评分 74 → 78 → 81)。本版本不引入新功能,仅修复 7 项 P0 线程安全问题 + 1 项 P0 安全问题 + 2 项 P1 状态一致性问题 + 1 项 P2 文档补充。落地后经编译验证补丁修复 3 处编译错误(T7 变量作用域 + P1#12/L7 缺失导入),
:app:assembleDebug与:app:assembleRelease均构建通过,详见 §10.136。
另含 T2(分批 Flow 化):百度配置读取路径全部由runBlocking(IO)迁移到baiduConfigFlow.first()(suspend),分两批落地(第一批外部 UI/App 调用点;第二批 AppPreferences 15 个便捷方法 + BaiduPrefs 透传 + BaiduOAuthClient/Netdisk/MvFileService/ViewModel 调用方),详见 §10.137。
另含 T6(AudioFrame 双缓冲):频谱数据仓库由单实例改为双缓冲 +@Volatile writeIndex发布,消除音频回调线程写/渲染线程读的无同步撕裂;VisualizerStage 绘制循环改为每帧捕获 front 引用,详见 §10.138。
另含 T3(PlayerManager 三元组原子化):queue/currentIndex/currentSong 由三个独立 MutableStateFlow 合并为单一playerState原子流(update{copy}同帧发布),消除快速切歌时 UI 读到"新队列+旧索引+旧歌名"的错帧状态;PlayerViewModel/AppRoot/QueueBranch/MainViewModel/DownloadViewModel/PlaybackService 订阅点全部适配,详见 §10.139。
另含 S4 落地(Jellyfin 会话内 401 重认证):全量报告中最后一项未完成的安全类问题(原列「暂缓:需真实环境测试」)。executeJsonRequest现在会在 401 时重认证并重试一次,防循环由「非递归 + 第二次结果照单全收 + 重认证不经过本方法 + 无凭据即放弃」四重保证;并发下 N 个 401 只触发 1 次登录。新增 Robolectric + MockWebServer 回归测试 5 用例(断言请求次数上界)。详见 §10.149。
另含 P1#5(可视化随机源隔离)——⚠️ 不是缺陷修复,修的是「VisualizerMathKDoc 声称纯函数、实则持有可变单例状态」这一矛盾与零调用方的死代码resetRandom();改动面实测仅 8 个类(报告估 30+)。详见 §10.150。
另含 L3 落地(playModeToggleHandler改 SharedFlow)—— 报告原描述的「配置重建窗口期回调 NPE」经核实不成立(ViewModelStore 被保留 + destroy→create 不返回 Looper);真正修掉的是「onDestroy的清空被if (!isFinishing) return拦截 → Application 长期持有已onCleared的 ViewModel 闭包」。刻意零缓冲(不用报告建议的extraBufferCapacity=1,否则会出现「一进应用播放模式自己跳一档」),无订阅者时丢弃与旧实现handler==null同义。详见 §10.151。
另含 P1 性能清单落地 4 项(P1#3 Crossfade 等功率曲线 / P1#4 SleepTimer 协程化 / P1#6hueOf零分配 / P1#7 Milkdrop 预分配 Canvas);P1 清单另 4 项经复核判定不宜按报告原建议直接实施(P1#11 设计取舍 / P1#9 非等价优化 / P1#8 需算法改写 / P1#5 需改 30+ 渲染器),逐条理由详见 §10.148。⚠️ 本机测试 worker 无法启动,新增的虚拟时间用例与 S4 的 5 条 MockWebServer 用例均未经运行,须由 CI 验证。
Fixed
- K 歌 / ONNX 专项修复(player): 落地
logs_temp/code-review-karaoke-onnx-2026-09-14.md的 2 项 P0 + 3 项 P1 + 5 项 P2(P2-d / P2-e 为 2026-09-14 二次审计后追加),详见 §10.146。本机./gradlew testDebugUnitTest始终无法运行(测试 worker 启动即死,exit 268435466),故另用kotlin-compiler-embeddable绕过 Gradle 做独立 JVM 数值验证:39 条断言全部通过,且对已提交的LinearResamplerTest.kt本体跑出OK (10 tests)。真机听感仍未验证:- P0-1 采样率归一化(
DemucsSeparator.kt): MediaCodec 不重采样、codec.configure也改不了KEY_SAMPLE_RATE,48kHz 源此前原样进入按 44100Hz 设计的模型,而 WAV 头硬编码 44100 → 伴奏时长缩短 8.8%、音高升高约 1.5 个半音。新增流式线性插值重采样器LinearResampler(inRate == outRate时整体旁路),解码阶段统一归一化到 44100Hz,下游分段/overlap-add/WAV 头/时长计算只有一处真相 - P0-2 单声道源错乱(
DemucsSeparator.kt):channelCount此前读出但从未使用,无条件按 L/R 成对读 → 单声道源被当成「两倍帧数的立体声」,输出时长减半、播放翻倍速且升八度。改为按真实声道数拆帧(单声道复制为 L/R;>2 声道取前两路并跳过其余),并新增INFO_OUTPUT_FORMAT_CHANGED处理以codec.outputFormat为准 - P1-3 张量泄漏(
DemucsSeparator.kt):processSegmentFromBuffer原为「先取值、再 close」,session.run()抛异常或强转失败时输入张量(~2.75MB)与OrtSession.Result(~11MB)native 内存双双泄漏,被separate()的catch (e: Exception)吞掉后静默累积。改为嵌套 try/finally;同时补输出 shape 校验(错误信息携带实际 shape)
-...
- P0-1 采样率归一化(
NASMusicTV v2.32.1
[v2.32.1] - 2026-09-13
修复 TV 端 K 歌与 MTV 全屏页面手机遥控二维码不显示/无法通过遥控器按键重新唤醒的问题。根因一(主因):AppRoot 的
isTV仅检查android.software.leanback特性,而实测电视(pm list features)只上报android.hardware.type.television无 leanback——TV 被误判为手机,remoteControlUrl被if (isTV)守卫强制置 null,二维码完全无法生成(v2.20.0 手机端支持引入的回归,当时 MainActivity/NasMusicApp/TextInputDialog 均用双特性判断、唯 AppRoot 漏检)。根因二:页面级按键预览未建立稳定焦点域,内部 TV 按钮或 MTV 的 AndroidView 视频层获得焦点后,按键不保证经过外层监听。现 isTV 对齐全库统一的双特性判断,并为两页建立可聚焦的页面焦点域主动请求焦点、仅在 KeyDown 刷新显隐计时;二维码在进入页面及任意遥控器按键后立即显示,约 5 秒无操作后完全隐藏,同时保留原有 D-Pad、播放控制、焦点导航和 BACK 行为。
Fixed
- K 歌/MTV 手机遥控二维码不显示(根因修复):
AppRoot的isTV补上android.hardware.type.television检测(与 MainActivity setContent 内、NasMusicApp、TextInputDialog 的既有判断对齐),TV 不再被误判为手机;MainActivity.onCreate的系统栏隐藏判断同步对齐 - K 歌/MTV 手机遥控二维码按键后不显示:两页外层增加
FocusRequester + focusable()页面焦点域,页面进入时主动请求焦点,确保内部按钮与 MTV 视频层场景下仍能收到按键预览 - 二维码显隐计时重复刷新:仅在
KeyEventType.KeyDown时刷新 5 秒计时,避免同一次按键的 KeyUp 再次延长显示时间
Changed
- K 歌页控制按钮对齐 MTV 定时隐藏:底部栏(返回/上一首/播放暂停/下一首/升降调/变速/原唱伴唱/质量切换)5 秒无操作虚化至 0.15 透明度,任意遥控器按键或焦点变化恢复;歌词与歌曲信息保持常显
- 版本 v2.32.0 → v2.32.1(versionCode 139 → 140)
[v2.32.0] - 2026-09-13
可视化效果库一次补齐 11 套极简几何风格效果(E26–E36):E26「声弦」线性声波(平行细线阵随波形起伏,高频叠加细密锯齿)、E27「几何环」动态几何环(pulse 心跳缩放 + treble 积分旋转 + 顶点频谱断点闪烁)、E28「构成」包豪斯拼贴(莫兰迪色系扁平几何体,低音放大/高频翻面/中频漂移)、E29「轨道」环绕轨道(倾斜椭圆轨道 + 光球拖尾环形缓冲,倾角低通晃动防筛子)、E30「雷达」极坐标(雷达绿单色,低音向心收缩 + 扫掠角余弦亮起 + 频谱余辉弧)、E31「折纸」低多边形(三角形 cos 投影翻折,过零交换明暗面;treble 驱动冷暖色相插值)、E32「阶梯」方波(刻意不做缓动的量化方块立面,高频 2×2 碎裂)、E33「齿轮」同心齿轮(齿轮 Path 预生成,beat 棘轮一个齿距 120ms 缓动,小齿轮 treble 疯转)、E34「分形」分形树(拓扑 onEnter 拍平存数组不递归,bass 驱动展开深度,末梢确定性电弧)、E35「光轴」旋转光轴(宽淡辉光+细亮芯线双层,高频手动分段虚线避开 dashPathEffect 每帧分配)、E36「螺旋」费马螺旋(黄金角预计算点位,内圈组整体旋转规避逐点变换)。全部 Tier.BASIC(三档画质全开,内部按画质分档细节量),draw 内零分配(E30 SweepGradient 预分配、E26 单 Path 双描边、E29 环形历史缓冲零 arraycopy)。音频分析层零改动,舞台/指示器/切换零改动(自动遍历 selectable);均衡器页小预览按 ordinal%3 归类样式天然兼容。
Added
- 11 套极简几何可视化效果(E26–E36):
VectorWavesRenderer/PulsingPolygonsRenderer/BauhausShapesRenderer/OrbitalRingsRenderer/RadarGridRenderer/OrigamiPolyRenderer/StaircaseWaveRenderer/ConcentricGearsRenderer/FractalTreeRenderer/LightBeamsRenderer/FermatSpiralRenderer(4 个新文件),枚举VisualizerTheme编号 26–36 全 Tier.BASIC;VisualizerRendererFactory注册 11 分支 - 画质内部分档:全部效果按 LOW/MEDIUM/HIGH 缩放元素量(线数/形状数/轨道数/网格密度/分形深度/光束数/点数),LOW 档亦可流畅
Changed
VisualizerThemeTest数量断言 23 → 34;版本 v2.31.4 → v2.32.0(versionCode 138 → 139)
[v2.31.4] - 2026-09-13
修复设置页内容区无法用遥控器左键把焦点移回左侧导航栏的问题(用户报告:仅播放设置分区复现,其他分区偶发/正常)。根因:设置页为「左栏分区导航(verticalScroll Column)+ 右栏内容(LazyColumn)」双滚动容器布局,焦点跨容器移动依赖系统几何查找;播放设置内容最长(整段为单个 LazyColumn item,含多组横排按钮/开关/路径文本),焦点在内容区内部左右移动时几何查找无法"走出"到左栏,表现为按左键焦点卡住。修复采用 Compose TV 标准的焦点越界重定向:右栏声明
focusGroup并挂focusProperties { exit },向左越界(FocusDirection.Left)时强制聚焦左栏当前分区项(每个分区项持有独立FocusRequester,activeSection驱动)。对全部分区生效,且不影响内容区内部(横排按钮组之间)的正常左右移动;其他方向(上/下/右)越界维持系统默认行为。用户确认的交互预期:导航栏选中分区 → 右键进入内容修改查看 → 左键逐步移回导航栏 → 继续选其他分区。
Fixed
- 设置页内容区按左键无法移回导航栏:
SettingsScreen右栏 LazyColumn 增加focusGroup+ 左向exit重定向到左栏当前分区项;分区导航项挂FocusRequester
Changed
- 版本 v2.31.3 → v2.31.4(versionCode 137 → 138)
[v2.31.3] - 2026-09-13
修复设置域子页面遥控器返回键无法回到设置主菜单的问题。BACK 键 Level 2 导航分发表(
AppRoot)为「白名单 +else -> navigateHome兜底」模式:从设置进入的均衡器(Screen.Equalizer)与播放统计(Screen.PlayStats)没有专门分支,静默落入兜底直接回首页——页面左上角返回按钮点击可回设置,但遥控器返回键行为与之不一致,用户只能移动焦点"点"按钮返回。现按入口来源补齐分发:均衡器/播放统计/服务器连接 → 回设置,网盘(入口唯一在"我的")→ 回我的页;天气电台入口在首页,由兜底回首页保持正确。设置页内部弹窗(备份/模型传输/文本输入/目录选择/导出设备)的返回键关闭均正常,无需改动。
Fixed
- 均衡器/播放统计按返回键回不到设置:Level 2 分发表补
Screen.Equalizer/Screen.PlayStats→navSettings分支,与页面返回按钮行为一致 - 网盘页按返回键回不到"我的":补
Screen.Netdisk→navigateMine分支(入口唯一:MineBranch)
Changed
- 版本 v2.31.2 → v2.31.3(versionCode 136 → 137)
[v2.31.2] - 2026-09-13
统一歌曲/歌词来源标签体系。修复播放页来源标识硬编码
"NET":无论百度网盘、Meting 网络曲、Jamendo 还是电台,只要isNetworkSong=true一律显示写死的 "NET"(NowPlayingScreen从未接入MusicSourceType体系)。现改用统一SourceBadge(百度→"百度"☁ 橙 / Meting→"网络"🌐 绿 / Jamendo→"Jamendo"♪ 粉 / 电台→"电台"📻 紫,NAS→"NAS"🎵 蓝也补齐显示)。歌曲信息面板「网络来源」行原显示原始标识大写(BAIDU/METING),改为统一sourceType.displayName且全来源显示;LyricsSource(歌词来源)补齐icon/color字段与歌曲来源定义结构对齐,播放页歌词来源切换标签文案由 strings.xml 独立维护改为统一取枚举displayName(内嵌/本地/在线/缓存),两套文案不再漂移;发现页专辑角标沿用MusicSourceType文案与颜色(样式为封面角标紧凑版,维持现状)。
Fixed
- 播放页来源标签硬编码 "NET":
NowPlayingScreen来源标识改用统一SourceBadge(song),按MusicSourceType正确显示「百度 / 网络 / Jamendo / 电台 / 天气电台 / NAS / 本地 / 已下载」及对应主题色 - 歌曲信息面板来源文案不统一:
SongInfoPanel「网络来源」行由networkSource?.uppercase()(BAIDU/METING 原始标识)改为sourceType.displayName,行名改「歌曲来源」,全部来源均显示 - 歌词来源两套文案漂移:删除 strings.xml 的
player_highlight_backend/local/network/cached(中英双语),播放页歌词来源切换标签统一取LyricsSource.displayName
Changed
LyricsSource与MusicSourceType结构对齐:新增icon/color字段(颜色语义对齐:内嵌→蓝 / 本地→橙 / 在线→绿 / 缓存→青),displayName统一为短版文案- 清理无引用资源
song_info_unknown_source(中英);版本 v2.31.1 → v2.31.2(versionCode 135 → 136)
[v2.31.1] - 2026-09-13
修复「下载到本地的歌曲无法播放」。本地/已下载歌曲的
file://URI 永久有效,但持久化层(上次队列/最近播放/本地歌单/备份恢复)此前对所有歌曲统一置空streamUrl,恢复后播放地址丢失;而播放解析链(resolveStreamUrl/resolveAndPlayCurrentSong)只认「网络歌曲 / NAS 歌曲」两种来源,本地歌曲(id 为local_*)被误当 NAS 歌曲去后端查询必然失败,部分入口还会因空 URI 被onPlayerError静默吞掉——表现为点播无反应或自动跳歌。现改为仅网络歌曲置空streamUrl(本地歌曲保留)、解析链补全本地分支(streamUrl缺失时回退path),并新增「已下载优先」:网络歌曲播放/切歌/批量播放前先查下载记录,已下载且文件存在直接播本地文件(支持离线播放)。
Fixed
- 恢复队列/最近播放/本地歌单中的本地歌曲无法播放:
AppPreferences6 处持久化(saveLastQueue/recordRecentSongObject/recordPlay/addSongToPlaylist/ 备份恢复×2)改为经stripVolatileStreamUrl()仅对isNetworkSong置空streamUrl,本地歌曲保留file://URI - 播放解析链误把本地歌曲当 NAS 歌曲解析:
PlayerViewModel.resolveStreamUrl/resolveAndPlayCurrentSong由二分支(网络 / NAS)扩为三分支,isLocalSong直接用本地 URI(streamUrl缺失回退path),不再拿local_*id 去adapter.getSongsByIds()查询失败 updateRestoredQueueStreamUrls排除本地歌曲:nasSongIds筛选与合并条件增加!it.isLocalSong,不再向 NAS 后端发起无意义的local_*查询playQueue空 URL 静默失败:needsResolve与首曲回填覆盖isLocalSong(历史持久化数据streamUrl已被置空,用path回填),避免空 URI 触发onPlayerError被静默吞掉
Added
- 已下载优先(离线播放):
DownloadRepository.playableLocalUri(song)(仅 COMPLETED 且本地文件存在非空才返回file://URI);NetworkMusicViewModel.playNetworkSong/PlayerViewModel.resolveStreamUrl(网络分支)/MainViewModel.playNetworkBatch(首曲)在解析直链前先查下载记录,已下载直接播本地文件——断网也能播已下载歌曲
Changed
- 版本:v2.31.0 → v2.31.1(versionCode 134 → 135)
[v2.31.0] - 2026-09-13
新增「催眠」(E25)可视化效果:数学函数图像动画。缓慢描线(8s)画出带坐标轴/刻度/标签的数学函数图像,静止凝视 3s 后图像溃散蒸发(2.4s)、空场 0.5s 再随机换下一张——53 条函数(笛卡尔 27 + 参数曲线 8 + 极坐标 8 + 彩蛋 10)一次性全部实现,Fisher-Yates + soft 加权洗牌保证单周期不重复、柔和曲线偏早出现、跨周期交界不重图。屏幕右侧独立"公式带"(18% 屏宽、垂直居中)经自研
FormulaLayout渲染真数学样式(嵌套上标e^{-x^2}、真分式、根号横线;上标转普通字形绘制,完全绕开 U+2070 区字体覆盖问题),并随曲线共用同一溃散时间轴按 run 碎散——上标飞离宿主、分式线跟着分子断开。与歌曲完全解耦:渲染器结构上不读songId,切歌/暂停/静音/后台返回均不打断当前图像的"描线→静止→溃散"(超时保护推进 DRAW 前先补满描线进度,杜绝后台返回后"只画 1% 就静止")。溃散双档:LOW/MED 逐点抖动蒸发(点数按画质减半),HIGH 粒子化坍缩(切线初速 + 重力 + 节拍脉冲 ×1.35)。音频联动克制(描线变速 ≤1.15×、辉光/线宽微调),静音时效果完整可看。FormulaLayout/FunctionLibrary纯 JVM 可单测,6 个新测试文件 34 个用例;app/build.gradle.kts新增testOptions.unitTests.isReturnDefaultValues支持渲染器构造的纯 JVM 测试。
Added
- 「催眠」(E25)数学函数图像动画:
HypnoticFunctionRenderer(四态状态机8000/3000/2400/500ms)+ 枚举VisualizerTheme.HYPNOTIC_FUNCTION("催眠",Tier.BASIC,编号 25,三档画质全开);VisualizerRendererFactory注册分支 FormulaLayout:数学公式源标记 → 排版 run 的自绘排版引擎(纯 JVM)。支持嵌套上标^{}、下标预留、真分式frac{}{}、根号√{}+ 横线、^2/²等价归一;花括号不配对降级为字面绘制;3 行断行 + 缩字号兜底(下限 26f);词/运算符边界断行,+/-粘后块保持负数项完整FunctionLibrary:53 条函数定义(FunctionDef支持uGap断点双段域)+ 三类采样器(归一化输出、自动 y 范围、NaN/Inf/跳变断笔、渐近线大值尾巴剔除)+ 三步加权洗牌(Fisher-Yates → 全局 soft 加权降序 → 跨周期防重交换)- 坐标轴语义:主轴画在数学 x=0 / y=0 的真实位置(归一化 ±1.05 内可见、越界钳制),π 域函数用 π 刻度、其余整数刻度、参数/极坐标只画网格不打数字
- 测试:
FunctionLibraryTest/FormulaLayoutTest/HypnoticPhaseTest/HypnoticScheduleTest/HypnoticLayoutTest/HypnoticDissolveTest(34 用例:53 条采样有限性、间断分段、闭合曲线、洗牌覆盖/防重/确定性/songId 无关、排版几何、状态机时长、溃散阈值带)
Fixed
- 只有第一张图有描线过程,后续函数直接出全图:
GAP → DRAW迁移时未清零drawAccumulator——首图靠onEnter归零,之后每个周期累加器残留8000ms,新图第一帧即满足"描线完成"直接进 HOLD。现迁移时归零
Changed
app/build.gradle.kts:新增testOptions { unitTests.isReturnDefaultValues = true }(渲染器构造含Paint,纯 JVM 单测需要)VisualizerThemeTest:枚举总数断言 21 → 23(该文件此前已滞后于源码:E23/E24 加入后实际为 22,未同步),并补HYPNOTIC_FUNCTION三档支持断言;头部注释同步
[v2.30.5] - 2026-09-13
新增「心跳」可视化效果(E24):心电图式滚动频谱。一条连续折线从屏幕最右侧生成扫描点,已绘制的波形冻结并整体向左匀速平移,最左端超出屏幕被裁掉。只取鼓点——不由宽频时域波形驱动,仅在鼓点命中时注入一个完整的 P-QRS-T 心搏复合波,人声/中高频背景不参与;相邻鼓点之间贴中心基线走平。基线固定在屏幕垂直中线,心搏波相对基线上下都有振幅(P/R 波向上,Q/S 波下探)。鼓点判定走双通道:
frame.beat官方标志 或frame.pulse上升沿——后者用于补回「节拍检测跑 50Hz/渲染数据仅 30fps(EMIT_INTERVAL_MS=33)」采样下被跳过的单帧标志,解决"有鼓点却不出波形"。幅度取新增的AudioFrame.bassRaw(未峰值归一化的低频原始能量)除以自身慢速均值——不能用bass,它经boost()峰值跟随后在鼓点瞬间恒为 1.0,会让每次鼓点长得一模一样;映射到 0.45–1 保留强弱差异。心搏波拉长到约 0.50s,并设 800ms 最短渲染间隔(≈75 BPM 上限)丢掉过密的连续鼓点,保证每个心搏完整、彼此不粘连;主峰高度由 0.40 半屏压到 0.26。复古 CRT 绿线风格,峰顶无任何圆点。环形历史缓冲零分配、零 arraycopy,滚动按列虚拟时间匀速;三档画质全可用。舞台/交互/入口零改动(自动遍历 selectable)。
Added
- 「心跳」(E24)心电图式滚动频谱:新增
EcgWaveRenderer与枚举VisualizerTheme.ECG_WAVE(Tier.BASIC,三档全开);VisualizerRendererFactory注册分支
Fixed
- 只有第一张图有描线过程,后续函数直接出全图:
GAP → DRAW迁移时未清零drawAccumulator——首图靠onEnter归零,之后每个周期累加器残留8000ms,新图第一帧即满足"描线完成"直接进 HOLD。现迁移时归零
Changed
AudioFrame新增bassRaw(20–250Hz 未归一化原始线性能量):bass走boost()峰值跟随归一化,鼓点瞬间恒为 1.0,无法表达鼓点强弱;需要绝对强弱对比的效果改用本字段
[v2.30.4] - 2026-09-12
针对最近三次提交(737ae0c / 1cda585 / ddaef2d)的代码审查结果集中修复:万花筒几何错误、歌词点阵切歌不更新、可视化封面取色 403、Path 批处理丢失的逐元素 alpha、控制行按钮高度不齐、歌词信息每帧重复计算。
Fixed
- 万花筒(E10)8 扇区塌陷:把
withTransform手写成显式旋转时,线段内端点误用只含rotation的角(rotCos/rotSin),外端点用k*45°+rotation(baseCos/baseSin),两端角度不一致 → 8 个扇区的内端全塌到同一方向,只剩 k=0 一瓣正确,画面呈“章鱼”状。现内外端统一用扇区角 - 歌词点阵(E23)切歌后永远停在上一首:
swapper = remember { RendererSwapper() }无 key,切歌不会重建渲染器、不走onEnter;displayedLineIndex仅在< 0时初始化,且行切换只认 `di...
NASMusicTV v2.29.2
[v2.29.2] - 2026-09-11
Changed
- 智能电台多源化(F2-3 演进):推荐池从 NAS 单源扩展为 NAS + 本地(含下载)+ Meting 网络歌单采样(随机抽 3 个歌单、每单 30 首,tagged isNetworkSong),distinctBy 去重后统一打分——网络歌曲无 genre/albumId 时相应加分项自然失效,仍可入池推荐;SmartRadioManager 新增 startFromScratch 无种子启动(以 play_counts 最高歌曲为偏好种子,纯加权随机)
- 智能电台入口迁移(F2-3 演进):NowPlaying 控制按钮行入口移除,改为首页独立区块(天气电台卡片上方,随心听下方);与当前播放歌曲解耦,不依赖 NAS 连接(多源池至少有本地+网络兜底)
- 智能电台交互列表化(F2-3 手测反馈):首页区块从"点卡片直接播放"(天气电台式)改为随心听式浏览列表——进入首页自动生成 20 首推荐批次(SmartRadioManager 新增 generateOnly:只生成不播放),区块展示 SectionHeader + 歌曲卡片横滑行,点任意卡片整批入队从该首播起,header 右侧"换一批"重新生成(保留电台种子,排除已推荐歌曲);移除 SmartRadioCard 与 startSmartRadio/startSmartRadioFromCurrent 死代码
[v2.29.1] - 2026-09-11
Fixed
- 播放统计"本月"Tab 恒为空(F2-1 缺陷,release 专属):ProGuard/R8 缺少
data.stats包 keep 规则——PlayStatsRepository的 GsonTypeToken匿名类泛型签名被擦除后,fromJson返回原始LinkedTreeMap,+1触发ClassCastException被 catch 静默吞掉,月度键(play_stats_monthly)从未写入;读取端同理解析失败降级空表。单测全绿是因为 Robolectric 不跑 R8。修复:proguard-rules.pro 补-keep class com.nasmusic.tv.data.stats.** { *; }(与 v2.5.1 Gson 类型擦除崩溃同类坑,AGENTS.md 已有警示"新增序列化模型时勿删 keep 规则")。mapping.txt 验证:data.stats 类恢复 identity 映射 - 睡眠定时弹窗布局优化(F2-2b 手测反馈):定时按钮从 NowPlaying 顶栏独占行移至歌词来源标签行(A+ 字号按钮右侧);弹窗改紧凑布局——标题"定时关闭"、中间 -/[N 分钟]/+ 步进(5 分钟步长,5-300)、下方两行(15/30 快捷档 + OK/取消定时),去掉按钮内重复的"定时"字样(新增 np_sleep_timer_min 短格式字符串)
- 通知栏按钮不刷新/锁屏无自定义按钮(F2-2 遗留缺陷):media3
MediaLibraryService自带默认通知 Provider,与自建多按钮通知共用 ID=1 互相覆盖,导致下拉通知栏样式漂移、状态不同步。改为setMediaNotificationProvider接管,通知统一由本服务buildNotification渲染 5 按钮;Android 13+ 锁屏/超级岛系统媒体卡片不读通知 action、由 MediaSession custom layout 渲染,新增setCustomLayout+SessionCommand(onConnect/onCustomCommand)注入播放模式/睡眠定时两个自定义键;compact view 索引修复为 (0,1,2)(原 (1,2,3) 实际显示 播放/暂停、下一首、播放模式,漏掉上一首) - 睡眠定时器入口隐蔽(F2-2b):NowPlaying 顶栏右侧新增常驻小按钮(未启动显示"定时 -"、运行中橙色显示"定时 N 分钟"),点击弹出档位选择窗(15/30/60/90 分钟 + 运行中可取消),手机触摸/TV D-Pad 通用;原"仅运行中显示"状态条移除
Changed
- 重构:AppRoot(1155 行)
when(currentScreen)14 个 Screen 分支全部提取至ui/components/branches/包(Home/NowPlaying/Library/Mine/Queue/Settings/ServerConnect/AlbumDetail/ArtistDetail/Equalizer/PlaylistManagement/Netdisk/WeatherRadio/PlayStats Branch),AppRoot 精简为路由壳(391 行),外层共享状态参数化注入、pickerSong弹窗状态保留在宿主经回调上抛。根除 JVM 单方法 64KB 上限(MethodTooLargeException)——此前 F2-2b 仅新增 3 个参数即触顶;实测 AppRoot 方法字节码 8303 条指令、最大分支 SettingsBranch 10437 条(上限 65535)。后续新增屏幕必须新建独立 Branch 文件
NASMusicTV v2.29.1
[Unreleased]
[v2.29.1] - 2026-09-11
Fixed
- 通知栏按钮不刷新/锁屏无自定义按钮(F2-2 遗留缺陷):media3
MediaLibraryService自带默认通知 Provider,与自建多按钮通知共用 ID=1 互相覆盖,导致下拉通知栏样式漂移、状态不同步。改为setMediaNotificationProvider接管,通知统一由本服务buildNotification渲染 5 按钮;Android 13+ 锁屏/超级岛系统媒体卡片不读通知 action、由 MediaSession custom layout 渲染,新增setCustomLayout+SessionCommand(onConnect/onCustomCommand)注入播放模式/睡眠定时两个自定义键;compact view 索引修复为 (0,1,2)(原 (1,2,3) 实际显示 播放/暂停、下一首、播放模式,漏掉上一首) - 睡眠定时器入口隐蔽(F2-2b):NowPlaying 顶栏右侧新增常驻小按钮(未启动显示"定时 -"、运行中橙色显示"定时 N 分钟"),点击弹出档位选择窗(15/30/60/90 分钟 + 运行中可取消),手机触摸/TV D-Pad 通用;原"仅运行中显示"状态条移除
Changed
- 重构:AppRoot(1155 行)
when(currentScreen)14 个 Screen 分支全部提取至ui/components/branches/包(Home/NowPlaying/Library/Mine/Queue/Settings/ServerConnect/AlbumDetail/ArtistDetail/Equalizer/PlaylistManagement/Netdisk/WeatherRadio/PlayStats Branch),AppRoot 精简为路由壳(391 行),外层共享状态参数化注入、pickerSong弹窗状态保留在宿主经回调上抛。根除 JVM 单方法 64KB 上限(MethodTooLargeException)——此前 F2-2b 仅新增 3 个参数即触顶;实测 AppRoot 方法字节码 8303 条指令、最大分支 SettingsBranch 10437 条(上限 65535)。后续新增屏幕必须新建独立 Branch 文件
[v2.29.0] - 2026-09-10
Added
- 播放统计面板(F2-1):新增月度播放统计(play_stats_monthly 键,recordPlayWithSong 同次 DataStore edit 原子写入,滚动保留 12 个月)+ 统计页(本月/累计 Tab、播放次数/歌曲数 KPI、最爱歌手 Top10、流派分布原生 Canvas 条形图);入口在设置 → 数据管理 → 播放统计;聚合器纯函数可单测(PlayStatsAggregatorTest 8 用例)
- 睡眠定时器(F2-2):SleepTimerController(不持久化,档位 15/30/60/90 分钟,时间源可注入)+ 到期自动暂停;通知栏按钮显示剩余分钟(每分钟刷新)+ NowPlaying 顶栏状态条(点击快速设置 30 分钟)
- 通知栏增强(F2-2):按钮 3→5(新增播放模式循环切换、定时关闭,自定义 action 广播经 RECEIVER_NOT_EXPORTED 注册,API 22/33+ 双兼容);下一首 subText(队尾不显示);compact view 仍为核心 3 键
- 智能电台(F2-3):基于当前歌曲流派+歌手生成相似随机流——RadioSongScorer 打分纯函数(同歌手+50/同流派+30/同专辑+10/同年代+5/播放偏好加权/已播强排除,加权随机采样)+ SmartRadioManager 状态机(两级曲库加载:流派先筛≥50 否则全量分页 5000 硬上限,曲库耗尽自动换批);入口在 NowPlaying 控制按钮行(NAS 已连接且非网络歌曲时显示);首期仅 NAS 曲库(网络歌曲无 genre)
- 断线续播(F2-4):断网时冻结错误跳歌(networkLost 标志,onPlayerError 不跳歌不重解析,记录断点 ResumePoint),网络恢复后 2 秒去抖重解析当前歌续播(wasPlaying=false 只加载不播);MainViewModel.onNetworkAvailable/Last 接线,既有 3 次重连逻辑不变
- 跨曲交叉淡入淡出(F2-5):CrossfadeController 双实例方案——切歌窗口主 player 旧歌淡出、crossfadePlayer 新歌淡入(50ms 步进线性斜坡),窗口结束 transitionToIndex 完成切歌;设置开关+时长档位(2/4/6/8/12s,默认关闭);K歌/MTV 模式(suppressPlayback)、单曲循环、队列仅 1 首、下一首 streamUrl 为空均不触发;手动切歌立即中断;crossfadePlayer 不持音频焦点
- 音质分级(F2-6):Meting 源 br 参数 + 降级链(无损999→320→128,AUTO 不传 br);BandwidthEstimator 滑动窗口带宽估计(30s/32样本,>10Mbps→999 / 2-10Mbps→320 / <2Mbps→128);设置页 4 档选择(自动/无损/320k/128k);NAS 原品质直传不受影响
[v2.28.1] - 2026-09-10
Changed
- 重构(N 系列,2026-09-10,经所有者确认实施):MainViewModel 兼容转发层消除——12 个子 VM 公开为只读属性(手动 DI,不引入 Hilt),删除 121 个纯透传转发、保留 12 个胶水转发,AppRoot/NetdiskScreen/MainActivity/MediaKeyHandler 改经子 VM 直调,MainViewModel 3186→3055 行(N-1)
- 重构:DomainPrefs.kt(179 行 10 类)拆为 10 个独立子 Prefs 文件,data/prefs/ 达 14 文件单类单文件(N-2)
- 重构:AppRoot 17 个单分支独占 collectAsState 订阅下沉至 when 分支内,顶层订阅 35→18 处(≤20 达标),未组合的 Screen 不再收集不重组(N-3)
- 重构:PlayerManager(64.1KB)拆分——HQ 人声分离编排提取 HqSeparationOrchestrator(PlayerHost 窄接口,延续 R-5 模式)、均衡器/频谱提取 PlayerEqualizer,PlayerManager 精简为播放核心+队列状态机(41.5KB),外部调用面零改动(N-4;MediaSession/音频焦点实为 PlaybackService 职责,不涉及)
Fixed
- 封面缓存清除不生效:
clearCoverCache()用coil.ImageLoader(context)工厂函数创建全新无配置实例,其memoryCache/diskCache与 UI 实际使用的全局 ImageLoader(NasMusicApp.newImageLoader(),已注入百度 dlink UA 拦截器)不是同一个,清除操作打在了无人使用的实例上、磁盘 100MB 缓存目录也未被清理。改为Coil.imageLoader(context)取全局单例,与PlaybackService/CoilBitmapLoader同一实例。顺带消除该处ExperimentalCoilApi未 opt-in 告警 - CI:
keystore.properties缺失时不再阻塞单测任务——signingConfigs仅在配置存在时创建 release 签名,signingConfig改用findByName(返回 null)替代getByName(配置期抛NoSuchElementException);此前file("")在配置期抛IllegalArgumentException,导致 CI 的testDebugUnitTest整体失败。test job 同步补显式 Android SDK 安装步骤,不再依赖 runner 预装的隐含状态。 - 网络歌曲队列播放中断:某首歌链接过期失败跳过后,下一首不自动播放(需手动点播放恢复)。根因:出错后 ExoPlayer 处于 IDLE 状态,
next()的seekToNextMediaItem()既不触发onMediaItemTransition(索引不同步、空 URL 懒解析检测失效)也不重新prepare,播放器静默停住。修复:新增transitionToIndex()手动恢复路径(同步索引 + 空 streamUrl 触发onNeedResolveStreamUrl解析 + seekTo/prepare/play),next()/previous()检测到 IDLE 时统一走该路径(随机模式同策略排除已播历史);onMediaItemTransition空 URL 检测从仅 AUTO 放宽到 AUTO|SEEK;onIsPlayingChanged(true)时重置lastErrorRetryIndex,同一首歌成功起播后若链接再次过期仍可自动重解析一次(原仅切歌时重置)
[v2.28.0] - 2026-09-10
Changed
- 重构:MainViewModel(5451 行)按 W0 冻结清单拆分为 13 个领域子 ViewModel(WeatherRadio/Backup/Playlist/Download/MvSearch/VocalSeparation/Server/Search/NetworkMusic/Player/Navigation/PlayHistory + 事件契约 ViewModelEvents),MainViewModel 精简为协调者 + 兼容转发层(3186 行),AppRoot 引用保持不变;详见 docs/codebase-refactoring-plan-2026-09.md R-1
- 重构:BaiduConnectionState 归属从 MainViewModel 迁移至 NetworkMusicViewModel
- 重构:SettingsScreen 拆分为 9 个 Section(ui/screens/settings/,State/Actions 分组签名),主文件 2529→924 行(R-2)
- 重构:LibraryScreen 五个 NAS 浏览 Tab 迁至 ui/screens/library/browse/,主文件 1695→647 行,详情页复用现役实现(R-3)
- 重构:5 个后端适配器注入 BackendRegistry 共享 OkHttp 连接池/Dispatcher,close() 不再 shutdown 全局线程池——连续切换后端不再累积线程池(R-6,用户可感知:切后端后封面/播放请求更稳定)
- 重构:人声分离 SeparationMode 上提 player 层 + 新增 VocalSeparationController(R-5)
- 重构:AppPreferences 按领域拆分为 12 个子 Prefs 门面(server/player/lyrics/network/baidu/download/weather/visualizer/history/playlist/queue/languagePrefs/backup),全库调用点迁移至 prefs..xxx(R-4)
- 重构:Subsonic 系公共层下沉 SubsonicRestClient(Navidrome 全量委托;Subsonic 因 URL 格式差异保留自有 buildRestUrl)(R-10 部分)
- 重构:ExportCoordinator/AccompanimentCache 统一注入 applicationScope;歌词持久缓存写互斥(F-4/F-5)
Fixed
- 安全:五个后端适配器(Jellyfin/Navidrome/Subsonic/道理鱼/飞牛)的 w/e 级错误日志统一经 UrlSanitizer 脱敏,release 包 logcat 不再泄露 api_key / Subsonic 密码令牌 / 登录 token(重构方案 F-1);新增 UrlSanitizerTest 单测 8 项
- 安全:天气 OpenWeatherMap API Key 改 AES-GCM 加密存储,旧明文值一次性迁移清除(F-7)
- ANR:消除主线程 runBlocking——语言设置改 SharedPreferences 双写镜像,8 个 provider 键改 @volatile 内存镜像(设置改动 ≤1s 生效,R-7 + F-3);新增 ProviderMirrorTest 6 项
- 性能:AppRoot 顶层 progress/duration 收集下沉至播放页分支,播放期间不再每秒驱动全树重组(F-2)
- 修复:TV 双网卡(有线+无线)场景扫码 IP 可能取错网段——NetworkUtils 改接口优先级排序,有线/在用接口优先(F-6)
说明:本轮按 docs/codebase-refactoring-plan-2026-09.md v1.4 实施 R-1
R-7、R-9、R-10(Subsonic 公共层)与 F-1F-7;R-8(Hilt 迁移)与 R-10 的 Jellyfin 域拆分经所有者确认定案不实施(兼容性风险 / 实例状态耦合深)。
[v2.27.0] - 2026-09-09
Fixed
- 安全:移除全部 6 处 trust-all TLS 配置,恢复系统默认证书校验(主播放器数据源 / Coil 图片 / 百度 OAuth / Meting / 道理鱼 / 飞牛 / B 站 MV)
- 安全:清除连接页硬编码的开发者 NAS 账号密码(该凭据已随历史提交公开,需轮换)
- 修复:高质量人声分离临时目录改用应用私有 cacheDir,原 /data/local/tmp 不可写导致 HQ 分离下载必败
- 修复:扫码传模型服务器改用独立端口 18083,不再与手机遥控服务器冲突
- 修复:网盘文件索引原子写盘(临时文件 + rename),写盘中断不再丢失全库索引
- 修复:百度 token 被服务端判定失效时自动强制刷新重试一次(errno -6/31045)
- 修复:网盘搜索补传 start 分页参数;listAll 索引游标停滞保护;清库同步清目录倒排
- 修复:Range 请求仅接受 206,防服务器忽略 Range 时整文件读入内存
- 修复:退出确认 disconnect 限时 1.5s,消除主线程 ANR 风险
- 修复:Android 13+ 通知运行时权限请求(媒体通知此前可能不显示)
- 修复:「全部加入队列」改为只增不删,不再反向移除已入队歌曲
- 修复:Activity 重建(旋转/主题切换)不再误杀后台播放(onDestroy 仅 isFinishing 时清理)
- 修复:Meting API 的 id 参数补 URL 编码;BaiduPanApi / 道理鱼日志 token 脱敏;网盘歌词侧车 dlink 补 access_token
- 性能:频谱 20fps 状态流下沉至播放页收集;设置页组合内同步读改 Flow 订阅
- 杂项:CI 增加单元测试步骤;EncodingUtils 空 catch 补日志;AGENTS.md 同步最新架构事实
[v2.26.41] - 2026-09-09
多源融合大改版,终于告一段落了!
Fixed
- 播放页点击艺术家/歌名未跳转到搜索页:
AppRoot.kt的 NowPlayingonSearchArtist/onSearchSong接线错误地调用viewModel.searchNetworkSongs(keyword),该方法只更新独立的网络音乐搜索数据流_networkSearchResults,既不导航也不设置曲库搜索关键词,导致点击后界面停留播放页、无任何跳转。修复:改为沿用已有的onNavigateToSearch模式(selectLibraryTab(LibraryTab.SEARCH)+setLibrarySearchKeyword(keyword)+navigateTo(Screen.Library)),跳转到曲库 SEARCH Tab 并触发跨源融合搜索(NAS + 网络 + 百度 + Jamendo + 本地)。
[v2.26.40] - 2026-09-09
Fixed
- 曲库搜索/发现/歌曲页面的加入歌单(
+)无反应:AppRoot.kt的pickerSong状态被声明了两次——L128 顶层(供加入歌单弹窗PlaylistPickerDialog读取)与 L454 的Screen.Library分支内(遮蔽了顶层)。曲库页onAddToPlaylist = { song -> pickerSong = song }设置的是 L454 的遮蔽变量,而弹窗渲染读取的是 L128 的顶层变量,两者不是同一个 → 设置后弹窗读不到 → 永不弹出。修复:删除 L454 的冗余遮蔽变量,让曲库页的pickerSong引用顶层变量,与专辑/艺术家详情页、我的页、网盘页的加入歌单逻辑一致。
[v2.26.39] - 2026-09-09
Fixed
- 搜索页 / NowPlaying / 我的页的本地/下载歌曲无法收藏:
AppRoot.kt三处收藏接线(NowPlaying / LibraryScreen / MineScreen)只判断song.isNetworkSong,漏判song.isLocalSong,导致本地/下载歌曲落入 NAS-only 的viewModel.toggleFavorite()——NAS adapter 收到local_xxxID 必然失败,收藏静默无效。同时MainViewModel.toggleFavorite与toggleNetworkFavorite的 NAS 分支逻辑完全相同,属冗余函数。修复:统一改为viewModel.toggleNetworkFavorite(song)(内部已按isNetworkSong || isLocalSong分流:NAS→adapter,其他→DataStore),删除冗余的toggleFavorite。 - 专辑/艺术家详情页的本地歌曲收藏后爱心不亮:
AppRoot.kt的 AlbumDetail / ArtistDetail 传入viewModel.favoriteIds(NAS-only),未与networkFavoriteIds合并;而toggleNetworkFavorite已将本地收藏保存到 DataStorenetworkFavoriteIds。结果收藏实际已持久化,但favoriteIds集合不含该 ID,isFavorited = song.id in favoriteIds恒为 false,爱心永不亮。修复:MainViewModel.favoriteIds改为combine(_favoriteIds, networkFavoriteIds)的统一合并集合,AppRoot 各屏幕统一读取(LibraryScreen 移除+ networkFavoriteIds散点拼接),NowPlaying 的isFavorite显示同步修正。
Changed
- 收藏架构收敛为单一路径:NAS 歌曲走服务端 adapter,网络/本地/下载歌曲走本机 DataStore
NetworkFavoriteItem;UI 侧统一订阅合并后的favoriteIds,不再在各屏幕自行按歌曲类型分流判断。
[v2.26.38] - 2026-09-08
Fixed
- 下载歌曲封面/歌词永远不内嵌:
SongDownloadManager.singleAttempt()中tagWriter.embed()传入的是临时文件p.tmpFile,其扩展名为.part,而MediaTagWriter.supportsEmbedding()检查file.extension是否在EMBEDDABLE集合中——.part不在其中,导致embed()第一行就返回 false,封面和歌词永远走旁路.jpg/.lrc文件。修复:将原子 rename 移到 embed 调用之前,对p.finalFile(有正确.mp3/.flac扩展名)执行内嵌。 - 已下载歌曲在搜索/列表中不使用本地封面和歌词:
UnifiedSongRow直接用song.coverUrl(后端 URL),不检查本地旁路封面;getCoverCandidates()不查本地下载记录;loadLyricsForCurrentSong()不读本地.lrc文件。修复:DownloadState.Completed扩展携带coverPath/lyricPath/embedded;UnifiedSongRow和getCoverCandidates()优先使用本地file://封面;loadLyricsForCurrentSong()优先通过LocalLyricsProvider.getLyricsFromPath()读取本地歌词。 - 扫码传输备份上传失败:
BackupTransferServer的 HTML 模板中,strings.xml的html_backup_confirm_restore含\n换行符,经getString()解析后变为真实换行,插入 JS 单引号字符串导致语法错误,整个<script>块不执行,页面 JS 全部失效(无GET /api/list、无 alert、无 POST)。改用gson.toJson()序列化 STR 对象,自动转义特殊字符。同时前端readAsText改为readAsArrayBuffer+Blob避免文本编码问题。 - 扫码传输备份导入后中文乱码:
handleUpload使用session.parseBody()读取 POST body,NanoHTTPD 内部用 `...