Repository navigation
Conversation
新增网易云官方「心动模式」接口接入与本地降级双通道:
- platform: NeteaseClient 增加 eapi playmode/intelligence/list 调用与
PlaymodeIntelligenceResponseParser 解析器。实测 data[] 元素是包装对象
{id, alg, recommended, songInfo},歌曲字段在 songInfo 内,解析器按
songInfo/song/songDetail/data 依次取容器,songInfo 缺 id 时回填外层 id。
- playback/logic: HeartbeatNextTrackSelector 契约与 HeartbeatNextTrackCoordinator
优先官方源、仅在官方返回 null 时走本地源;官方源成功时清失败计数,
同一首歌连续 3 次失败才熔断,reset() 恢复。
- playback/runtime: PlayerManagerHeartbeatExtensions 负责开关态、预取、
插播 NEXT 与状态持久化;OfficialHeartbeatSource 适配 platform 仓库。
- app: NowPlayingHeartbeatControl 播放页控件 + Snackbar 文案
(已开启 / 已开启(本地推荐)/ 已关闭),DI 绑定与持久化映射。
- 测试: playmode parser 5 例(含真实包装样本、外层 id 回填、类型错误回退、
纯包装对象不误判),heartbeat coordinator 6 例,另加回归用例
「failing for one song does not permanently block the official source」。
真机验证(小米 22041211AC,登录态):开启→官方预取成功→连续 2 次
insertHeartbeatSongAndPlay 官方候选均正确入队播放,无解析失败日志。
一、修复「开启心动模式后首次点下一首重复播放当前歌」
- insertHeartbeatSongAndPlay 原先调用 playAtIndex(updatedQueue.currentIndex),
而 queueEdits.insert(NEXT) 沿用「按 identity 找回原当前歌」的策略,
返回的 currentIndex 仍指向插入前的歌曲,导致命中心动候选时播回原歌。
改为 planned.queue.selecting(planned.insertedIndex),让下标指向刚插入的候选。
- 插入成功后补发 requestHeartbeatSelectionAsync("afterInsert") 预取下一首,
避免第二次点下一首时池空回落原队列。
- heartbeatAdvanceOrFallback 中 ready == null 时先执行 fallback 再发起预取,
因为预取以 _currentSongFlow.value 为锚,提前发起会锚在即将切走的歌上。
二、UI 调整(用户反馈第 1、2 点)
- 心动模式按钮从主播放控制行移到封面操作栏(最下面那一栏)第 6 位:
NowPlayingCoverToolbarActions 增加 onHeartbeat,NowPlayingCoverToolbarStatus
增加 heartbeatActive,resolvePlaybackActionToolbarLayout 的 itemCount 参数化,
常量 PlaybackActionToolbarItemCount = 5 保持不变,不影响 LyricsScreen 与
StartupOnboardingExtras 两个复用方。
- 删除 NowPlayingHeartbeatControl.kt,新建 NowPlayingHeartbeatFeedback.kt 承接
模式变化与候选耗尽两段提示逻辑(独立成文件以守住 NowPlayingScreen.kt 的
2000 行预算门禁,当前 1976 行)。
- 播放页自身挂载 NeriOverlaySnackbarHost,条件为 !showMoreOptions,
避免与 MoreOptionsSheet 内的 host 同 hostState 双渲染。此前播放页没有
自己的 host,心动提示只能由「更多选项」面板的 host 渲染,用户看到的效果是
「提示在右上角菜单里弹出、不点进去不消失」。
三、按任务书补齐第三、四阶段
- 评分(§3.4):删除 RECENTLY_PLAYED_PENALTY,改为按累计播放次数衰减的
repeatPenalty(0.85^n,下限 0.25);HeartbeatCandidate 增加 recentPlayCount 与
effectiveRepeatCount。
- 候选池(§3.3):新增 supplemental 原料层,选歌时按同歌手/同专辑即时派生
层级 3/4,避免把依赖当前歌曲的判定固化进 5 分钟 TTL 缓存。
- 偏好数据:LocalHeartbeatCandidateProvider 接入 PlaybackStatsRepository,
librarySamples 与 recentPlayCounts 均由 RECENT 排序的统计页驱动。
- 失败排除(§八):LocalHeartbeatCandidateSource 增加 markFailed/resetFailures,
播放失败经 dispatchFailureAdvance 记入黑名单;重新开启心动模式时清空。
- 缓存(§3.6):关闭心动模式时 invalidateHeartbeatCandidateCache()。
四、验证
- 单测:heartbeat 相关 6 个测试类共 77 例、playmode 解析测试、新增
NowPlayingHeartbeatFeedbackTest 3 例、行数预算门禁全部通过。
- 真机(小米 22041211AC / Android 14):开启后底部提示「心动模式已开启」
(官方接口可用,无「本地推荐」后缀);首次点下一首直接切到官方候选
「告白/515453664」,不再重播当前歌;队列由 33 首增至 35 首,前 4 首为原队列,
心动候选追加在其后(方案 A:追加不替换);关闭后连续点下一首无任何心跳日志。
五、明确未实现
- 「不喜欢 / notLikedWeight」:仓库内无该数据流,本轮未实现,不做臆造。
- count 固定为 1;官方返回结果不做任何客户端二次排序或过滤(§1.3 红线)。
任务书 §四 步骤 3「设置项中的说明」要求提供一段静态说明,同时承担
合规告知作用(§5.3 文案红线:本地降级不得表述为「官方推荐」)。
## 改动
- `modules/common/src/main/res/values{,-zh,-en}/strings_settings_general.xml`
新增 `settings_heartbeat_section_title` / `settings_heartbeat_section_desc`。
说明文案严格按任务书原文,两行分列「已登录走官方」「未登录走本地」,
不使用「官方算法」等夸张表述。
- `app/.../component/playback/SettingsPlaybackSection.kt`
在「下一首淡入淡出」卡片之后新增第 5 张卡片(cardIndex = 4),
只用 `MiuixSettingsSectionIntro` 渲染标题 + 说明,不含任何开关。
- `app/.../tab/settings/SettingsScreen.kt`
播放页卡片循环 `0..3` → `0..4`,否则新卡片不会被挂载。
## 为什么不做成开关
任务书 §四 步骤 3 标注为「可选但推荐」,且需求是「一段静态说明」。
心动模式的开关入口在播放页封面工具栏(第 6 位),设置页重复一个开关
会产生两处状态源。本轮只做说明,不引入新的设置项,也不改动
`AutoSettingsSchema` 与搜索索引。
## 验证
- `:app:compileDebugKotlin` BUILD SUCCESSFUL
- `:app:assembleDebug` BUILD SUCCESSFUL
- `:app:testDebugUnitTest --tests "*SettingsPageTest*" --tests "*Budget*"` 通过
(搜索锚点未变:新卡片不含可搜索设置项,`playbackCardIndex` 无需改动)
图标(方案 B,用户 m04062 选定)
- ic_heartbeat_pulse_24.xml 重写为线稿心形轮廓 + 内部 ECG 折线,两根 path 均为
strokeColor 描边、fillColor 透明。
- 原实现的 pathData 是一条未闭合折线却用 fillColor,Android 隐式闭合后填成
扭曲的细楔形,所以看起来「不太合适」。改描边后形状所见即所得。
- 去掉硬编码 android:tint="#303238",颜色完全交给调用方
toolbarActiveTint(status.heartbeatActive, status.activeColor, LocalContentColor.current),
激活态才能正常变色。
- 用线稿而非实心爱心:播放栏的实心爱心语义已被「收藏」占用,线稿与工具栏其它
线稿图标(随机/循环/下一首)风格一致,不会与收藏混淆。
提示(用户 m04139)
- 开启/关闭心动模式不再弹任何提示。开关是高频轻操作,每次都给一条提示属于噪音。
- 只有候选池连续 3 次选不出歌、播放器已回退到原有模式时才提示,文案沿用
heartbeat_candidates_insufficient。
- 样式回到项目原有的底部 Snackbar(NeriOverlaySnackbarHost 渲染),不再自绘顶部浮层;
停留时长压到 1 秒(showNeriSnackbar 后到点 dismissCurrentNeriSnackbar)。
SnackbarDuration.Short 实际约 4 秒,对一句话提示太长。
- 官方接口临时失败不提示:那会静默降级到本地推荐,心动模式仍在工作,
任务书 §四 步骤 2 也要求「官方临时失败不提示」。
顺带修掉的 bug
- 旧的「跳过首次组合」写法是先判状态值再判首组合,初始值为 false 时永远走不到
首组合分支,导致第一次真实候选耗尽被吞掉、要第二次才提示。改成先判首组合。
验证
- :app:compileDebugKotlin BUILD SUCCESSFUL
- :app:testDebugUnitTest --tests "*NowPlayingHeartbeatFeedbackTest*" \
--tests "*NowPlayingScreenTest*" --tests "*OwnedMainSourceLineBudgetTest*" 通过
- :app:assembleDebug BUILD SUCCESSFUL
任务书 §3.4 的 notLikedWeight 之前因全仓无 dislike 数据流而列为未实现, 本次补齐。经接口探测确认网易云没有「给单曲打不喜欢」的通用接口 (/api/v2/discovery/recommend/dislike 是日推语义、/api/radio/trash/add 是私人FM语义,均不适配),因此名单只能本地保存。 存储:DataStore 存 JSON(照 ExploreSearchHistoryRepository 模式), 不进 Room。理由是名单量级只有几十到几百条、无 SQL 需求,而 Room 加表 要把库版本 20→21、登记迁移、导出 schema、补两套迁移测试,漏登会让老 用户升级后打不开数据库。上限 500 条,超限淘汰最旧的。 生效范围: - 本地降级推荐:HeartbeatScorer 新增 NOT_LIKED_PENALTY = 10000.0,压在 最高层级基础分(4000)之上,被标记的收藏歌必然输给未标记的随机歌; 整池都被标记时 pickBest 返回 null,回退原有播放模式而不是硬推。 - 官方推荐:命中名单时丢弃这次返回并重新请求(最多 2 次),仍命中则 放行并打日志。这是对「官方结果不得二次过滤」的授权例外——不是对推荐 算法重排,而是用户已明确拒绝该曲;不改官方顺序与权重。 - 普通播放不受影响;当前正在播的歌不会立刻被切掉。 入口(用户指定):播放页「更多选项」面板的列表项 + 播放队列每行菜单, 图标 Icons.Outlined.ThumbDown,点一次加入、再点一次移出。 名单不进候选池缓存:候选池 5 分钟 TTL,而标记必须点完立刻生效。 测试:HeartbeatScorerTest 新增 4 例(惩罚压过最高层级/跳过被标记/ 全标记返 null/分数有限为负),LocalHeartbeatCandidateSourceTest 新增 4 例 (跳过被标记收藏/全标记返 null/每次选歌都读名单/读取失败按无标记处理), 新增 DislikedSongRepositoryTest 6 例(空载荷/坏 JSON/去空白/去重/ 上限裁剪/裁剪保留最新)。
心动模式下点「下一首」时,把当前歌的跳过次数记到本地 DataStore, 选歌时作为重复惩罚并入评分曲线,让常被跳过的歌逐渐排到后面。 - PlayerManager.next() 公开入口取当前歌调用 recordHeartbeatSkip, 内部 nextImpl 不记(避免一次点击记多次),自动播完不记 - HeartbeatSkipRepository 落盘 heartbeat_skip/skipped_v1,带 500 条上限 - LocalHeartbeatCandidateSource.withSkips 选歌时按 identityKey 命中注入 skipCount,刻意不进候选池缓存,保证「刚跳过这首」下次选歌就生效 - HeartbeatCandidate.penaltyRepeatCount = repeatCount + skipCount, 复用同一条 repeatPenalty 曲线(0.85^n,下限 0.25),不硬排除
跳过次数此前只有存储层测试覆盖,评分器侧没有任何断言能证明「跳过真的会降权」。 补 4 例: - 跳过次数越多分数越低 - 跳过与播放共用同一条衰减曲线(跳 2 次 == 播 2 次) - penaltyRepeatCount 把跳过次数叠在播放次数之上 - 整池都被跳过时仍能选出一首,证明跳过不是硬排除
心动模式原先用 insertNext 插播,而 insertNext 自带「歌已在队列就先移除」 的策略,队列长度永远不变;官方推荐本来就偏向用户收藏过的歌,于是表现为 十几首歌反复循环、偶尔混进一首新歌。 - 新增 PlayerQueueEditOwner.appendReplacingTail:先 take(currentIndex + 1) 截断到当前歌,再 insertEnd 接上新候选,同一次队列更新里完成。队列恒等于 「已播过的 + 心动流」,可无限往后长;截断只发生在真正切歌时,开关本身不动队列。 - 返回的队列下标已指向新候选,调用方直接 playAtIndex,避免 selecting 把下标 指回原歌(此前「开启心动后第一次点下一首会重播当前歌」的根因)。 - 与一起听互斥:进房间时经 ListenTogetherPlaybackHost.onListenTogetherSessionJoined 回调关闭心动模式并计数提示;房间内 setHeartbeatMode(ON) 直接拒绝。只拦开启 方向,关闭永远放行。不做「退出房间自动恢复」,避免用户不知情下改变选歌行为。 - 提示体系补齐四类:候选耗尽、进房间被自动关、房间内想开被拒、未登录开启 (成功也提示,全项目唯一例外,让用户知道当前是本地推荐)。统一停 3 秒, 靠首组合跳过过滤冷启动历史值。 - heartbeatModeStateFlow 挪到 setHeartbeatMode 末尾广播,保证 UI 收到「已开启」 时官方可用性已是刷新后的值。 对应任务书 §八「心动模式与一起听房间冲突」,以及单曲循环语义: REPLAY_CURRENT 提前返回,心动模式不接管单曲循环。
这三个入口都依赖网易云的数据,非网易云音源下没有意义。 - 新增 isNeteaseExclusiveFeatureAvailable(song):源为 B 站(PlayerManager.isBiliTrack) 或 YouTube(channelId == "youtubeMusic" 或 isYouTubeMusicSong)时返回 false。 isYouTubeMusicSource 从既有 isYouTubeMusicArtistNavigationSource 提取,两处共用。 - 心动按钮:NowPlayingCoverToolbarStatus 新增 heartbeatAvailable,false 时按钮不渲染, itemCount 同步从 6 降到 5,图标仍均分不留空格。常量 PlaybackActionToolbarItemCount = 5 未改,LyricsScreen 等复用方零影响。 - 「不喜欢这首歌」与「歌曲百科」:一起放进 if (neteaseExclusiveFeatureAvailable) 块。 判定只看歌曲对象自身的来源,不看实际播放地址:网易云歌曲因版权回落到 B 站音源 播放时,歌曲对象仍是网易云的,三个入口照常显示。
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 49294bfabb
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| fallback() | ||
| return false | ||
| } | ||
| val ready = heartbeatReadyNextSong |
There was a problem hiding this comment.
Bind prefetched candidates to their requesting song
When a recommendation for song A is pending or ready and the user switches directly to song B (for example via playFromQueue or playPlaylist), none of those paths clears or restarts heartbeatPending, and the store contains only a SongItem with no anchor identity. This line therefore accepts A's result on B's next/completion; appendHeartbeatSongAndPlay then truncates B's queue tail, and an in-flight A request can similarly overwrite a newer result. Publish and consume a request generation or current-song key and discard mismatches.
Useful? React with 👍 / 👎.
| return false | ||
| } | ||
| consumeHeartbeatReadyNextSong() | ||
| appendHeartbeatSongAndPlay(ready, activePlaybackCommandSource) |
There was a problem hiding this comment.
Preserve USB loud-playback confirmation on heartbeat skips
When playback is paused or otherwise not already audible and the USB-exclusive loudness policy requires confirmation, a user-initiated Next with a ready heartbeat candidate reaches this direct append/play path and returns before confirmNextTrackVolume, where requestUsbExclusiveLoudPlaybackConfirmation normally gates Next. That bypass starts the new track at the risky volume without the existing confirmation; route the user-skip path through the same guard while allowing automatic completion to remain bypassed.
Useful? React with 👍 / 👎.
| reason: String, | ||
| fallback: () -> Unit, | ||
| ): Boolean { | ||
| if (heartbeatModeState != HeartbeatMode.ON) { |
There was a problem hiding this comment.
Guard heartbeat advancement on unsupported sources
If heartbeat is enabled on a NetEase song and the user directly starts a Bilibili or YouTube track, the UI only hides the heartbeat control; no source-transition path turns the mode off. This guard checks the mode alone, so the next/completion path still consumes or requests a heartbeat candidate, may pass a foreign numeric ID to the NetEase endpoint, and can replace the unsupported track's queue while the switch is unavailable. Check the current source here or disable heartbeat when the source changes.
Useful? React with 👍 / 👎.
| val disliked = readDislikedIdentities() | ||
| var attempt = 0 | ||
| while (true) { | ||
| val song = fetchOnce(currentSong) ?: return null |
There was a problem hiding this comment.
Bypass the short cache when retrying a disliked result
When the first official candidate is disliked, fetchNextSong has just cached it under the current song ID for 30 seconds. Every iteration here calls the same method with the same key, so both advertised retries are cache hits rather than new /playmode/intelligence/list requests, and the code always reaches the retry limit and accepts that disliked song. Add a cache-bypass or invalidation path for these retries.
Useful? React with 👍 / 👎.
| if (heartbeatAdvanceOrFallback( | ||
| reason = "trackCompletion:$action", | ||
| fallback = { | ||
| nextImpl( |
There was a problem hiding this comment.
Avoid re-entering heartbeat selection from fallback navigation
On natural completion with no ready heartbeat result, this fallback calls nextImpl, which sees heartbeat still enabled, re-enters heartbeatAdvanceOrFallback, advances through its own fallback, and schedules a selection; after it returns, the outer wrapper schedules another selection at line 180. Playback-failure fallback has the same nesting, so a single transition launches two selector jobs and can issue duplicate official requests or race their publications. Invoke the non-heartbeat navigation path from these fallbacks or ensure only one wrapper schedules the request.
Useful? React with 👍 / 👎.
| repository.readPage( | ||
| query = PlaybackStatsQuery(sort = PlaybackStatsSort.RECENT), | ||
| pageSize = limit.coerceIn(1, 256), | ||
| ).tracks.associate { it.identityKey to it.playCount } |
There was a problem hiding this comment.
Normalize statistics keys before applying repeat penalties
PlaybackStatsRepository populates TrackStat.identityKey from SongItem.stableKey() (for a normal NetEase song this is shaped like 123|netease|), while candidate construction later looks up counts using HeartbeatScorer.identityKey(song) (typically :123 for parser-created NetEase items). Associating the stored keys directly here means those lookups miss and recentPlayCount stays zero, so cumulative repeat penalties do not affect the common remote-song path. Rekey the statistics with the heartbeat identity scheme or preserve a single shared identity representation.
Useful? React with 👍 / 👎.
| onClick = { onAddToEnd(); expanded = false } | ||
| ) | ||
| QueueRowFavoriteAction(isFavoriteSong) { onFavoriteToggle(); expanded = false } | ||
| QueueRowDislikeAction(isDislikedSong) { onDislikeToggle(); expanded = false } |
There was a problem hiding this comment.
Hide dislike actions for unsupported queued sources
The non-NetEase gate added to the toolbar and More Options is absent from the queue-row menu, so opening the menu for any Bilibili or YouTube entry still renders this action and persists an unsupported dislike identity. This contradicts the new source boundary for the same action; pass the song's availability into this menu and omit the item for unsupported sources.
Useful? React with 👍 / 👎.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
Walkthrough
Priority: Merge Risk:
|
There was a problem hiding this comment.
Actionable comments posted: 4
Note
Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.
🟡 Other comments: 5
app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingQueueSheet.kt: 1
685-685: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win队列菜单对所有音源都显示"不喜欢",与 B 站、YouTube 隐藏规则不一致。
PR 目标要求这些音源隐藏点踩入口。
MoreOptionsMainContent已用isNeteaseExclusiveFeatureAvailable隐藏该入口,队列行菜单没有。用户可以在队列里给 B 站或 YouTube 歌曲点踩。该键只写入本地名单,对这些音源没有推荐意义。建议按歌曲来源决定是否显示该菜单项,并把判定结果传入
QueueRowMoreMenu。modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/PlayerManagerHeartbeatExtensions.kt: 1
328-337: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win预取结果在模式关闭或歌曲切换后仍会被发布。
selectNext返回后,代码没有再检查模式状态和当前歌曲。触发路径如下:
- 预取进行中,用户关闭心动模式,或加入一起听房间。
setHeartbeatMode(OFF)清空heartbeatPending。- 预取协程随后调用
publish(next),把结果写回heartbeatPending。- 用户再次开启模式。在新预取返回前按"下一首",会消费这个过期候选。
同样,关闭后失败的预取会调用
onNoCandidate,可能触发"候选不足"提示。修复方式:发布前检查模式仍为
ON,且_currentSongFlow.value仍与currentSong同一身份。建议修改
+ if (heartbeatModeState != HeartbeatMode.ON || + _currentSongFlow.value?.let { HeartbeatScorer.identityKey(it) } != + HeartbeatScorer.identityKey(currentSong) + ) { + NPLogger.d(TAG, "预取结果已过期,丢弃(reason=$reason)") + return@launchBackgroundIo + } if (next == null) {modules/local/src/main/java/moe/ouom/neriplayer/data/heartbeat/HeartbeatSkipRepository.kt: 1
92-98: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win条目达到上限后,新跳过的歌曲永远无法被记录。
recordSkip把新键追加到merged末尾,计数为 1。条目数超过DEFAULT_SKIP_ENTRY_LIMIT后,sortedByDescending是稳定排序。计数同为 1 的条目保持插入顺序,新键排在最后,take(500)会先丢弃它。结果是:名单满 500 条且最低计数为 1 时,每次新跳过都被立即丢弃,跳过降权对新歌失效。这与DislikedSongRepository已修复的「刚点的这首被裁掉」是同一类问题。修复方式:裁剪时保留本次写入的键,再从其余条目中按计数淘汰。
🐛 建议修复
-internal fun trimToLimit(counts: Map<String, Int>): Map<String, Int> { +internal fun trimToLimit(counts: Map<String, Int>, keepKey: String? = null): Map<String, Int> { if (counts.size <= DEFAULT_SKIP_ENTRY_LIMIT) return counts - return counts.entries - .sortedByDescending { it.value } - .take(DEFAULT_SKIP_ENTRY_LIMIT) - .associate { it.key to it.value } + val kept = counts.entries + .filter { it.key != keepKey } + .sortedByDescending { it.value } + .take(if (keepKey != null && keepKey in counts) DEFAULT_SKIP_ENTRY_LIMIT - 1 else DEFAULT_SKIP_ENTRY_LIMIT) + .associate { it.key to it.value } + return if (keepKey != null) kept + (keepKey to counts.getValue(keepKey)) else kept }同时在
recordSkip中调用trimToLimit(merged, key)。modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/LocalHeartbeatCandidateProvider.kt: 1
48-57: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
syncSnapshot()在有未落盘变更时抛异常,播放后历史层级会间歇性为空并被缓存 5 分钟。
PlayHistoryRepository.syncSnapshot()在_history.value != persistedHistory时抛出IOException("Play history still has uncommitted changes")。每播放一首歌,历史都会先更新内存再异步持久化。心动模式正好在切歌时选歌,最容易落在这个窗口里。异常被buildPool的runCatching吞掉,历史层级变空。只要收藏不为空,这个缺少历史的候选池会被HeartbeatCandidateCache缓存 5 分钟。librarySamples也受同样影响。
syncSnapshot是给同步导出用的严格快照,选歌只需要当前内存视图。🐛 建议修复
override suspend fun recentHistory(limit: Int): List<SongItem> = withContext(Dispatchers.IO) { - runCatching { - playHistoryRepository.awaitHistoryLoaded() - } - playHistoryRepository.syncSnapshot() + playHistoryRepository.awaitHistoryLoaded() + playHistoryRepository.historyFlow.value .asSequence() .take(limit) .map(PlayedEntry::playedEntryToSongItem) .toList() }modules/platform/src/main/java/moe/ouom/neriplayer/platform/netease/playmode/NeteasePlaymodeIntelligenceRepository.kt: 1
73-85: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win缓存登录态信息,缩短心动候选的预取窗口。
requestHeartbeatSelectionAsync在后台协程中运行,因此这两个请求不会直接阻塞用户的“下一首”操作。若用户操作时heartbeatPending尚未就绪,播放链路会立即回退到原有队列;这两个串行请求会延长候选不可用的窗口,用户可能因此错过心动候选。
NeteaseClient.getCurrentUserId()和getLikedPlaylistId()当前每次都直接请求账户接口和歌单接口。请按登录会话缓存userId与红心歌单 ID,并在登录会话变化时失效缓存。
ℹ️ Review info
⚙️ Run configuration
- Source mode: Auto
- Repository: cwuom/NeriPlayer
- Branch: master
- File: .coderabbit.yaml
- Commit: 8c0bea4
- Layers: Repository YAML
- Review profile: QUIET
- Plan: Advanced
- Run ID:
f5afa27b-a472-4f79-9b28-9526d584c6b4
📒 Files selected for processing: 58
app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.ktapp/src/main/java/moe/ouom/neriplayer/core/di/player/AppHeartbeatBindings.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingHeartbeatFeedback.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingQueueSheet.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingScreen.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingSecondaryActions.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/actions/MoreOptionsMainContent.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/cover/NowPlayingCoverActionToolbar.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/SettingsScreen.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/playback/SettingsPlaybackSection.ktapp/src/test/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingHeartbeatFeedbackTest.ktmodules/common/src/main/res/drawable/ic_heartbeat_pulse_24.xmlmodules/common/src/main/res/values-en/strings_playback.xmlmodules/common/src/main/res/values-en/strings_settings_general.xmlmodules/common/src/main/res/values-zh/strings_playback.xmlmodules/common/src/main/res/values-zh/strings_settings_general.xmlmodules/common/src/main/res/values/strings_playback.xmlmodules/common/src/main/res/values/strings_settings_general.xmlmodules/listentogether/src/main/java/moe/ouom/neriplayer/data/ltw/ListenTogetherSessionManager.ktmodules/listentogether/src/main/java/moe/ouom/neriplayer/data/ltw/playback/ListenTogetherPlaybackHost.ktmodules/local/src/main/java/moe/ouom/neriplayer/data/heartbeat/DislikedSongRepository.ktmodules/local/src/main/java/moe/ouom/neriplayer/data/heartbeat/HeartbeatSkipRepository.ktmodules/local/src/test/java/moe/ouom/neriplayer/data/heartbeat/DislikedSongRepositoryTest.ktmodules/local/src/test/java/moe/ouom/neriplayer/data/heartbeat/HeartbeatSkipRepositoryTest.ktmodules/model/src/main/java/moe/ouom/neriplayer/data/model/playback/PersistedPlayerState.ktmodules/platform/src/main/java/moe/ouom/neriplayer/platform/netease/api/client/NeteaseClient.ktmodules/platform/src/main/java/moe/ouom/neriplayer/platform/netease/playmode/NeteasePlaymodeIntelligenceRepository.ktmodules/platform/src/main/java/moe/ouom/neriplayer/platform/netease/playmode/PlaymodeIntelligenceResponseParser.ktmodules/platform/src/test/java/moe/ouom/neriplayer/platform/netease/api/crypto/NeteaseCryptoPlaymodeEApiUriTest.ktmodules/platform/src/test/java/moe/ouom/neriplayer/platform/netease/playmode/NeteasePlaymodeIntelligenceRepositoryTest.ktmodules/platform/src/test/java/moe/ouom/neriplayer/platform/netease/playmode/PlaymodeIntelligenceResponseParserTest.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatCandidate.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatCandidateCache.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatCandidateExhaustionTracker.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatCandidateProvider.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatMode.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatNextTrackCoordinator.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatNextTrackSelector.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatPendingNextStore.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatScorer.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/LocalHeartbeatCandidateSource.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/policy/PlayerQueueEditOwner.ktmodules/playback/logic/src/test/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatCandidateCacheTest.ktmodules/playback/logic/src/test/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatCandidateExhaustionTrackerTest.ktmodules/playback/logic/src/test/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatNextTrackSelectorTest.ktmodules/playback/logic/src/test/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatPendingNextStoreTest.ktmodules/playback/logic/src/test/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatScorerTest.ktmodules/playback/logic/src/test/java/moe/ouom/neriplayer/core/player/queue/heartbeat/LocalHeartbeatCandidateSourceTest.ktmodules/playback/logic/src/test/java/moe/ouom/neriplayer/core/player/queue/policy/PlayerQueueEditOwnerTest.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/ltw/PlayerManagerListenTogetherHost.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PersistedPlayerStateMapping.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerStatePersistenceExtensions.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerQueueNavigationExtensions.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/LocalHeartbeatCandidateProvider.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/OfficialHeartbeatSource.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/PlayerManagerHeartbeatExtensions.kt
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.
| fun identityKey(song: SongItem): String { | ||
| song.sourceStableKey?.takeIf { it.isNotBlank() }?.let { return it } | ||
| if (song.id > 0L) { | ||
| val platform = song.matchedLyricSource?.name.orEmpty() | ||
| return "$platform:${song.id}" | ||
| } | ||
| return "${song.name.trim().lowercase()}|${song.artist.trim().lowercase()}" | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
#!/bin/bash
rg -nP -C3 '\bsourceStableKey\s*=' --type=kt -g '!**/test/**' | head -80
rg -nP -C3 '\bmatchedLyricSource\s*=' --type=kt -g '!**/test/**' | head -60
rg -nP -C5 'identityKey\s*=' modules/local modules/model --type=kt -g '!**/test/**' | head -80Repository: cwuom/NeriPlayer
Length of output: 247
🏁 Script executed:
set -u
printf '%s\n' '--- HeartbeatScorer ---'
sed -n '1,260p' modules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatScorer.kt
printf '%s\n' '--- TrackStat and identityKey references ---'
rg -n -F --glob '*.kt' -e 'data class TrackStat' -e 'class TrackStat' -e 'identityKey' modules app || true
printf '%s\n' '--- SongItem declarations and relevant assignments ---'
rg -n -F --glob '*.kt' -e 'sourceStableKey' -e 'matchedLyricSource' modules app || trueRepository: cwuom/NeriPlayer
Length of output: 43106
🏁 Script executed:
set -u
log=/tmp/coderabbit-shell-logs/shell-output-7xswST
printf '%s\n' '--- direct Heartbeat identity and related symbols ---'
rg -n -F -e 'HeartbeatScorer.identityKey' -e 'identityKey(' -e 'recentPlayCounts' -e 'toHeartbeatSongItem' -e 'withDisliked' -e 'OfficialHeartbeatSource' -e 'TrackStat' "$log" | head -240
printf '%s\n' '--- production source locations ---'
rg -n -F --glob '*.kt' -e 'recentPlayCounts' -e 'toHeartbeatSongItem' -e 'withDisliked' -e 'OfficialHeartbeatSource' -e 'TrackStat' -e 'HeartbeatScorer.identityKey' modules app || true
printf '%s\n' '--- SongItem identity declarations/helpers ---'
rg -n -F --glob '*.kt' -e 'data class SongItem' -e 'fun SongItem.stableKey' -e 'stableKey()' -e 'playbackStableKey' modules app || trueRepository: cwuom/NeriPlayer
Length of output: 45596
🏁 Script executed:
set -u
printf '%s\n' '--- heartbeat production files ---'
rg -l -F --glob '*.kt' -e 'recentPlayCounts' -e 'toHeartbeatSongItem' -e 'withDisliked' -e 'OfficialHeartbeatSource' -e 'HeartbeatScorer.identityKey' modules/playback modules/local app | sort
printf '%s\n' '--- heartbeat symbol locations ---'
rg -n -F --glob '*.kt' -e 'recentPlayCounts' -e 'toHeartbeatSongItem' -e 'withDisliked' -e 'OfficialHeartbeatSource' -e 'HeartbeatScorer.identityKey' modules/playback modules/local app | head -180
printf '%s\n' '--- TrackStat declarations and producers ---'
rg -n -F --glob '*.kt' -e 'data class TrackStat' -e 'class TrackStat' -e 'TrackStat(' -e 'identityKey = song' modules/local modules/playback app | head -180Repository: cwuom/NeriPlayer
Length of output: 26128
🏁 Script executed:
set -u
printf '%s\n' '--- LocalHeartbeatCandidateProvider ---'
sed -n '70,155p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/LocalHeartbeatCandidateProvider.kt
printf '%s\n' '--- OfficialHeartbeatSource ---'
sed -n '25,55p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/OfficialHeartbeatSource.kt
printf '%s\n' '--- PlaybackStatsRepository metadata ---'
sed -n '485,510p' modules/local/src/main/java/moe/ouom/neriplayer/data/stats/PlaybackStatsRepository.kt
printf '%s\n' '--- stable key implementation ---'
rg -n -F --glob '*.kt' -e 'fun SongItem.stableKey' -e 'fun stableKey' modules/local modules/model modules/playback || trueRepository: cwuom/NeriPlayer
Length of output: 7034
统一心动模式的歌曲身份键。
TrackStat 的 identityKey 会被 toHeartbeatSongItem() 丢弃。统计歌曲因此回退为 :id,而播放统计键通常是 netease:id。结果是重复播放惩罚、不喜欢标记和跳过计数可能无法命中。
请同时保留统计键,并让无 sourceStableKey 的歌曲使用稳定的平台标识。
🐛 建议修复
- val platform = song.matchedLyricSource?.name.orEmpty()
+ val platform = song.channelId.orEmpty() localFileName = localFileName,
+ sourceStableKey = identityKey,
)| while (true) { | ||
| val song = fetchOnce(currentSong) ?: return null | ||
| if (disliked.isEmpty() || HeartbeatScorer.identityKey(song) !in disliked) { | ||
| return song | ||
| } | ||
| if (attempt >= MAX_DISLIKE_RETRY) { | ||
| // 重试用尽仍然命中:放行,避免官方通道因本地名单而彻底空转 | ||
| NPLogger.d( | ||
| TAG, | ||
| "官方候选命中「不喜欢」名单且重试已用尽,放行: ${song.name}/${song.id}", | ||
| ) | ||
| return song | ||
| } | ||
| attempt++ | ||
| NPLogger.d( | ||
| TAG, | ||
| "官方候选命中「不喜欢」名单,重新请求($attempt/$MAX_DISLIKE_RETRY): ${song.name}/${song.id}", | ||
| ) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
「不喜欢」重试不会重新请求,始终拿到同一首被标记的歌。
fetchOnce 调用 repository.fetchNextSong(currentSong.id)。NeteasePlaymodeIntelligenceRepository 在 Success 时以 currentSongId 为键写入 30 秒缓存(putCache),下次调用先查 cachedSong。因此第一次命中名单后,第 2、3 次「重试」都直接返回缓存里的同一首歌,没有任何网络请求。循环最终走到「重试已用尽,放行」,被标记的歌照样播放。PR 描述的「命中不喜欢名单时最多重试两次」实际上从不生效。
修复方式:给仓库增加绕过缓存的入口,重试时使用。
🐛 建议修复
仓库侧:
suspend fun fetchNextSong(currentSongId: Long, bypassCache: Boolean = false): SongItem? {
if (currentSongId <= 0L) return null
if (!bypassCache) cachedSong(currentSongId)?.let { return it }
// ...
}本文件:
- val song = fetchOnce(currentSong) ?: return null
+ val song = fetchOnce(currentSong, bypassCache = attempt > 0) ?: return null- private suspend fun fetchOnce(currentSong: SongItem): SongItem? = try {
- repository.fetchNextSong(currentSong.id)
+ private suspend fun fetchOnce(currentSong: SongItem, bypassCache: Boolean): SongItem? = try {
+ repository.fetchNextSong(currentSong.id, bypassCache)另外,若命中名单的结果仍留在缓存中,30 秒内再次选歌也会返回它;重试成功后应以新结果覆盖缓存。
| internal fun PlayerManager.requestHeartbeatSelectionAsync(reason: String) { | ||
| if (heartbeatModeState != HeartbeatMode.ON) return | ||
| val selector = heartbeatSelectorHolder ?: return | ||
| val currentSong = _currentSongFlow.value ?: return | ||
| val queue = currentPlaylist.toList() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
rg -n 'isBiliTrack|isYouTubeMusicTrack|isYouTubeMusicSong' modules --glob '*.kt' | head -30
sed -n '300,430p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/PlayerManagerHeartbeatExtensions.kt
sed -n '130,300p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerQueueNavigationExtensions.ktRepository: cwuom/NeriPlayer
Length of output: 16231
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- heartbeat declarations and references ---'
rg -n -F --glob '*.kt' -e 'fun PlayerManager.setHeartbeatMode' -e 'setHeartbeatMode(' -e 'heartbeatAdvanceOrFallback(' -e 'requestHeartbeatSelectionAsync(' -e 'heartbeatSelectorHolder' -e 'appendReplacingTail' modules/playback/runtime modules/playback/logic app | head -240 || true
printf '%s\n' '--- source predicates and imports ---'
sed -n '1225,1275p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt
rg -n -F --glob '*.kt' -e 'isBiliTrack' -e 'isYouTubeMusicTrack' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player | head -100 || true
printf '%s\n' '--- heartbeat mode and selector implementation ---'
rg -n -F --glob '*.kt' -e 'class Heartbeat' -e 'interface HeartbeatNextTrackSelector' -e 'NeteasePlaymodeIntelligenceRepository' -e 'playmode/intelligence/list' -e 'currentSong.id' modules/playback/runtime modules/playback/logic app | head -240 || true
printf '%s\n' '--- full heartbeat mode block ---'
sed -n '1,180p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/PlayerManagerHeartbeatExtensions.kt
printf '%s\n' '--- song-change hooks ---'
rg -n -F --glob '*.kt' -e '_currentSongFlow.value' -e 'setCurrentSongForPlayback' -e 'currentSong =' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player | head -220 || trueRepository: cwuom/NeriPlayer
Length of output: 41549
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- mode setter and heartbeat public/internal functions ---'
sed -n '80,275p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/PlayerManagerHeartbeatExtensions.kt
printf '%s\n' '--- all heartbeat selector entry callers ---'
rg -n -F --glob '*.kt' -e 'tryAdvanceWithHeartbeat(' -e 'heartbeatAdvanceOrFallback(' -e 'requestHeartbeatSelectionAsync(' modules/playback/runtime app || true
printf '%s\n' '--- official source and repository contract ---'
sed -n '1,130p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/OfficialHeartbeatSource.kt
rg -n -F --glob '*.kt' -e 'class NeteasePlaymodeIntelligenceRepository' -e 'fun fetchNextSong' -e 'fetchNextSong(' modules app || true
printf '%s\n' '--- queue edit implementation ---'
sed -n '110,180p' modules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/policy/PlayerQueueEditOwner.kt
printf '%s\n' '--- song-change implementation and nearby hooks ---'
sed -n '560,660p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerPlaybackExtensions.kt
sed -n '175,210p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerQueueCommandExtensions.kt
printf '%s\n' '--- source gating in UI and playback entrypoints ---'
rg -n -F --glob '*.kt' -e 'isNeteaseExclusiveFeatureAvailable' -e 'heartbeatMode' -e 'HeartbeatMode' app modules/playback/runtime/src/main/java | head -240 || trueRepository: cwuom/NeriPlayer
Length of output: 34374
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- exact heartbeat paths ---'
nl -ba modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/PlayerManagerHeartbeatExtensions.kt | sed -n '208,244p;314,332p;348,365p;397,429p'
nl -ba modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerQueueNavigationExtensions.kt | sed -n '135,188p;212,232p;273,290p'
printf '%s\n' '--- official repository implementation ---'
nl -ba modules/platform/src/main/java/moe/ouom/neriplayer/platform/netease/playmode/NeteasePlaymodeIntelligenceRepository.kt | sed -n '1,100p'
printf '%s\n' '--- source predicates and media source resolution ---'
nl -ba modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt | sed -n '1248,1263p'
nl -ba modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/media/PlaybackMediaItemFactory.kt | sed -n '1,55p'Repository: cwuom/NeriPlayer
Length of output: 16459
对 Bilibili 和 YouTube Music 当前歌曲禁用心动接管。
heartbeatAdvanceOrFallback 未检查音源。当前歌曲为 Bilibili 或 YouTube Music 时,它仍会消费预取候选,并通过 appendReplacingTail 删除当前歌曲之后的队列。开启心动模式后直接切歌不会自动触发新的预取,但后续播放完成、播放失败或用户点下一首时仍可触发该接管路径。
requestHeartbeatSelectionAsync 也未检查音源。可达该入口时,OfficialHeartbeatSource 会把外部歌曲的 currentSong.id 传给 Netease 推荐接口。
请在预取入口和接管入口都跳过这两类歌曲,并清空待播放候选。普通队列推进需要绕过心动接管,避免接管入口的 fallback 再次进入心动包装。
建议修改
internal fun PlayerManager.requestHeartbeatSelectionAsync(reason: String) {
if (heartbeatModeState != HeartbeatMode.ON) return
val selector = heartbeatSelectorHolder ?: return
val currentSong = _currentSongFlow.value ?: return
+ if (isBiliTrack(currentSong) || isYouTubeMusicTrack(currentSong)) {
+ heartbeatPending.clear()
+ return
+ } private fun PlayerManager.heartbeatAdvanceOrFallback(
reason: String,
fallback: () -> Unit,
): Boolean {
if (heartbeatModeState != HeartbeatMode.ON) {
fallback()
return false
}
+ val currentSong = _currentSongFlow.value
+ if (currentSong != null &&
+ (isBiliTrack(currentSong) || isYouTubeMusicTrack(currentSong))
+ ) {
+ heartbeatPending.clear()
+ fallback()
+ return false
+ }
val ready = heartbeatReadyNextSong internal fun PlayerManager.nextImpl(
force: Boolean = false,
commandSource: PlaybackCommandSource = PlaybackCommandSource.LOCAL,
bypassLoudVolumeWarning: Boolean = false,
allowRememberedLongFormPosition: Boolean = false
) {
ensureInitialized()
if (!initialized) return
+ val currentSong = _currentSongFlow.value
+ if (currentSong != null &&
+ (isBiliTrack(currentSong) || isYouTubeMusicTrack(currentSong))
+ ) {
+ heartbeatPending.clear()
+ requestNextTrack(force, commandSource, bypassLoudVolumeWarning, allowRememberedLongFormPosition)
+ return
+ }
// 心动模式优先:用户主动跳过时也走心跳候选,避免「跳过一次就掉出心动歌流」(任务书 §3.5 步骤 6)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| internal fun PlayerManager.requestHeartbeatSelectionAsync(reason: String) { | |
| if (heartbeatModeState != HeartbeatMode.ON) return | |
| val selector = heartbeatSelectorHolder ?: return | |
| val currentSong = _currentSongFlow.value ?: return | |
| val queue = currentPlaylist.toList() | |
| internal fun PlayerManager.requestHeartbeatSelectionAsync(reason: String) { | |
| if (heartbeatModeState != HeartbeatMode.ON) return | |
| val selector = heartbeatSelectorHolder ?: return | |
| val currentSong = _currentSongFlow.value ?: return | |
| if (isBiliTrack(currentSong) || isYouTubeMusicTrack(currentSong)) { | |
| heartbeatPending.clear() | |
| return | |
| } | |
| val queue = currentPlaylist.toList() |
| planned.queue | ||
| } ?: return | ||
|
|
||
| consecutivePlayFailures = 0 |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
#!/bin/bash
rg -nP -C4 'consecutivePlayFailures|MAX_CONSECUTIVE_FAILURES' --type=kotlin modules/playback/runtime/src/mainRepository: cwuom/NeriPlayer
Length of output: 22119
🏁 Script executed:
set -e
file='modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/PlayerManagerHeartbeatExtensions.kt'
for range in '330,435' '435,535'; do
sed -n "${range}p" "$file"
done
printf '\n-- heartbeat-related callers --\n'
rg -n -F --glob '*.kt' -e 'dispatchFailureAdvance' -e 'heartbeatAdvanceOrFallback' -e 'appendHeartbeatSongAndPlay' modules/playback/runtime/src/main/java
printf '\n-- failure dispatch owner blocks --\n'
for f in \
modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/lifecycle/PlayerManagerLifecycleExtensions.kt \
modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerPlaybackExtensions.kt \
modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlaybackFailureRecoveryOwner.kt; do
echo "### $f"
rg -n -C12 -F --glob '*.kt' -e 'dispatchFailureAdvance' -e 'heartbeatAdvanceOrFallback' -e 'advanceAfterPlaybackFailure' -e 'recordFailure' "$f" || test "$?" -eq 1
doneRepository: cwuom/NeriPlayer
Length of output: 9586
🏁 Script executed:
set -e
file='modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerQueueNavigationExtensions.kt'
sed -n '115,305p' "$file"
printf '\n-- advanceAfterPlaybackFailure definitions and callers --\n'
rg -n -C18 -F --glob '*.kt' -e 'fun PlayerManager.advanceAfterPlaybackFailure' -e 'advanceAfterPlaybackFailure(' modules/playback/runtime/src/main/javaRepository: cwuom/NeriPlayer
Length of output: 34730
🏁 Script executed:
set -e
printf '%s\n' '-- PlaybackFailureRecoveryOwner --'
sed -n '33,115p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlaybackFailureRecoveryOwner.kt
printf '%s\n' '-- lifecycle failure branch --'
sed -n '735,815p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/lifecycle/PlayerManagerLifecycleExtensions.ktRepository: cwuom/NeriPlayer
Length of output: 7306
🏁 Script executed:
sed -n '800,875p' modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/lifecycle/PlayerManagerLifecycleExtensions.ktRepository: cwuom/NeriPlayer
Length of output: 3461
失败推进时不要清零连续失败计数。
播放器错误会递增 consecutivePlayFailures,但心动候选成功接管失败推进后,appendHeartbeatSongAndPlay 会立即将计数清零。因此连续失败可以持续触发选歌和播放请求,MAX_CONSECUTIVE_FAILURES 无法生效。
建议修改
internal fun PlayerManager.appendHeartbeatSongAndPlay(
song: SongItem,
commandSource: PlaybackCommandSource,
+ resetFailureCount: Boolean = true,
) {
@@
- consecutivePlayFailures = 0
+ if (resetFailureCount) consecutivePlayFailures = 0 private fun PlayerManager.heartbeatAdvanceOrFallback(
reason: String,
+ resetFailureCount: Boolean = true,
fallback: () -> Unit,
) {
@@
- appendHeartbeatSongAndPlay(ready, activePlaybackCommandSource)
+ appendHeartbeatSongAndPlay(
+ song = ready,
+ commandSource = activePlaybackCommandSource,
+ resetFailureCount = resetFailureCount,
+ ) if (heartbeatAdvanceOrFallback(
reason = "playbackFailure",
+ resetFailureCount = false,
fallback = {包边界:三个 runtime 侧心动文件从 core.player.queue.heartbeat 移到 core.player.heartbeat,与 module_boundaries.py 的归属保持一致。 预取锚点:HeartbeatPendingNextStore 改为按当前歌的 identityKey 存取, 避免「播 A 时预取的歌被用到 B 上」并误截断 B 的队列尾。 音源守卫:心跳接管与预取在选择前检查音源,B 站/YouTube 歌曲下直接 交回既有推进逻辑,不会把外部 id 发给网易云接口。 USB 高音量确认:用户主动下一首命中预取时不再绕过独占音量确认。 缓存绕过:「不喜欢」命中后的重试传 bypassCache,避免两次重试都命中 同一份 30 秒缓存、永远拿到同一首被标记的歌。 推进不再双发:完成/失败的兜底直接调 requestNextTrack,不再经 nextImpl 重新进入心跳接管,避免一次切歌发起两次选歌请求。 失败计数:播放失败接管后保留 consecutivePlayFailures,使 MAX_CONSECUTIVE_FAILURES 熔断能真正到达。 身份键:toHeartbeatSongItem 透传 identityKey,候选与统计库键对齐, 重复惩罚与跳过降权才能命中。 队列菜单:QueueRowMoreMenu 按新音源边界隐藏「不喜欢」,与封面工具栏一致。 行数门禁:PlayerManagerPersistenceExtensions 压缩到 2000 行以内。
There was a problem hiding this comment.
Actionable comments posted: 2
Note
Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.
Caution
Some comments are outside the diff and can’t be posted inline due to GitHub limitations.
🟠 Major · 在停止队列前处理心动候选。 · PlayerManagerQueueNavigationExtensions.kt:126-129
modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerQueueNavigationExtensions.kt:126-129
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win在停止队列前处理心动候选。
如果单曲队列以
REPEAT_MODE_OFF播放到结尾,trackCompletion返回STOP,这里会直接停止播放。即使心动模式已有就绪候选,后面的heartbeatAdvanceOrFallback也不会运行。先给心动模式推进机会;没有候选时再执行原有的停止行为。
🟡 Minor · 切换模式时调度状态持久化。 · PlayerManagerHeartbeatExtensions.kt:251-252
modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/heartbeat/PlayerManagerHeartbeatExtensions.kt:251-252
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win切换模式时调度状态持久化。
如果用户只切换心动模式就退出应用,
setHeartbeatMode不会调用scheduleStatePersist。持久化快照虽已包含heartbeatModeState,但磁盘上仍可能保留旧值;重启后开关会恢复到错误状态。在模式实际变化时调度持久化。
🟡 Minor · 仅在实际跳过歌曲后记录负反馈。 · PlayerManager.kt:1723
modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt:1723
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win仅在实际跳过歌曲后记录负反馈。
如果用户取消 USB 大音量确认,或下一首命令被房间控制规则拒绝,
nextImpl不会切歌,但这里已经记录一次跳过。重复尝试会持续降低当前歌曲的本地推荐分数。请在确认推进成功后记录,并保留一次用户操作只记录一次的语义。
🟡 Other comments: 1
modules/platform/src/main/java/moe/ouom/neriplayer/platform/netease/playmode/NeteasePlaymodeIntelligenceRepository.kt: 1
60-60: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win按登录账户隔离推荐缓存。
如果账户 A 获取推荐后在 30 秒内切换到账户 B,
cachedSong(currentSongId)会直接返回账户 A 的结果。缓存键只有歌曲 ID,命中时不会检查当前用户。请将用户 ID 纳入缓存键,或在账户变化时清空缓存,避免向账户 B 显示账户 A 的个性化推荐。
ℹ️ Review info
⚙️ Run configuration
- Source mode: Auto
- Repository: cwuom/NeriPlayer
- Branch: master
- File: .coderabbit.yaml
- Commit: 8c0bea4
- Layers: Repository YAML
- Review profile: QUIET
- Plan: Advanced
- Run ID:
d7d6bd84-e126-4354-a6f5-34f075b0b84f
📒 Files selected for processing: 15
app/src/main/java/moe/ouom/neriplayer/core/di/player/AppHeartbeatBindings.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingHeartbeatFeedback.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingQueueSheet.ktapp/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingScreen.ktmodules/platform/src/main/java/moe/ouom/neriplayer/platform/netease/playmode/NeteasePlaymodeIntelligenceRepository.ktmodules/playback/logic/src/main/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatPendingNextStore.ktmodules/playback/logic/src/test/java/moe/ouom/neriplayer/core/player/queue/heartbeat/HeartbeatPendingNextStoreTest.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/heartbeat/LocalHeartbeatCandidateProvider.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/heartbeat/OfficialHeartbeatSource.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/heartbeat/PlayerManagerHeartbeatExtensions.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/ltw/PlayerManagerListenTogetherHost.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerStatePersistenceExtensions.ktmodules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlayerManagerQueueNavigationExtensions.kt
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.
:playback:runtime:verifyCrap 会把本模块 scoped 文件的每个方法都算一遍 CRAP(复杂度平方 × 未覆盖度),超过 9 就失败。上一版把音源判断和 USB 音量确认直接塞进 nextImpl / dispatchFailureAdvance / playAfterTrackCompletion, 把它们的复杂度顶过了阈值。 改法:把新增分支挪进两个小函数(advanceWithGuards、useHeartbeatAdvance), 四个既有方法的复杂度回到改动前或更低: - nextImpl 只负责初始化检查,其余交给 advanceWithGuards - heartbeatAdvanceOrFallback 用 useHeartbeatAdvance() 合并「开关 + 音源」两个条件 - playAfterTrackCompletion / dispatchFailureAdvance 不再包一层 if 同时删掉已无引用的 clearHeartbeatPending:预取结果本身带身份锚点, 换歌后自然取不到,不再需要显式清理。
两条都来自 PR 评审,属于同一处状态机的边界: 1. 锚点与候选分两个 @volatile 字段写。后台协程写完新锚点、还没写新歌时, 播放线程可能拿「新锚点」读到「旧歌」,把为 A 预取的结果当成 B 的下一首播出去。 改成把两者打包成不可变对象,用 AtomicReference 一次性替换;take 用 getAndSet 保证同一候选只被消费一次。 2. 光有锚点挡不住「同一首歌上先关再开」。锚点没变,上一轮会话在途的请求回来后 仍能通过校验,被写进新一轮会话的待播槽。新增会话代次,开关状态每变一次 +1, 请求发起时记下代次,回来时对不上就丢弃。
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c344d13111
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // 心动模式优先:开启且能选出下一首时接管本次推进,否则完全沿用原有 nextStep 行为 | ||
| heartbeatAdvanceOrFallback( |
There was a problem hiding this comment.
Run heartbeat advancement before terminal completion
With repeat mode OFF, every heartbeat candidate is appended as the new last queue item. On natural completion, PlayerQueueNavigationOwner.trackCompletion therefore returns STOP, and dispatchTrackCompletion returns at lines 125–127 before this heartbeat call is ever reached; even an already-prefetched candidate is ignored, so the stream plays at most one recommendation and stops. Run heartbeat handling before the terminal stop branch while still preserving repeat-one behavior.
Useful? React with 👍 / 👎.
| val currentSong = _currentSongFlow.value ?: return | ||
| val anchor = HeartbeatScorer.identityKey(currentSong) | ||
| val generation = heartbeatSessionGeneration.get() | ||
| val queue = currentPlaylist.toList() |
There was a problem hiding this comment.
Exclude the replaceable queue tail from local scoring
When official selection is unavailable and playback was started from Favorites, favorites() returns the same songs as currentPlaylist; this full snapshot causes HeartbeatScorer.pickBest to exclude every future favorite as “already queued.” Those future entries are precisely the tail that appendHeartbeatSongAndPlay discards, so a user with no candidates outside that playlist gets no local heartbeat recommendation at all. Pass only the retained/played prefix through the current index, or otherwise distinguish the tail.
Useful? React with 👍 / 👎.
| originalPlaylistSize = data.playlist.size, | ||
| persistedIndex = data.index | ||
| persistedIndex = data.index, | ||
| heartbeatMode = data.heartbeatMode ?: HeartbeatMode.OFF, // Room 实体没有该列,恢复为 OFF |
There was a problem hiding this comment.
Persist heartbeat mode in the Room-primary snapshot
On the normal successful persistence path, persistPlaybackQueueWithRoomFallback writes Room and clears the legacy JSON, but PlaybackQueueStateEntity and both PlaybackQueueRoomStore.toEntity/readSnapshot omit heartbeatMode. Consequently data.heartbeatMode here is always null after restart for Room-primary users and the new switch is restored as OFF; add a migrated Room field or store this preference separately.
Useful? React with 👍 / 👎.
| val changed = heartbeatModeState != normalized | ||
| heartbeatModeState = normalized | ||
| if (changed) { |
There was a problem hiding this comment.
Schedule a state write when heartbeat mode changes
setHeartbeatMode mutates only the in-memory state and flow here; unlike repeat and shuffle changes, it never calls scheduleStatePersist. If the user toggles the mode while paused and Android kills the process before an unrelated queue/playback event schedules a snapshot, the stored value remains stale, so restart restores the wrong mode even after the Room storage is fixed. Schedule persistence for genuine mode changes while avoiding a redundant write during restore.
Useful? React with 👍 / 👎.
心动模式入口是封面工具栏的第 6 个动作,横屏布局复用同一个工具栏, 原先断言的 5 个动作已不再成立。改为断言 6 个,并把心跳回调接进 fixture,保证点击顺序断言覆盖到新增按钮。
verifyCrap 报 heartbeatAdvanceOrFallback 与 useHeartbeatAdvance 各 CRAP=12 (复杂度 3、无覆盖)。把两处各拆成一个分支的直线辅助函数, 每个方法复杂度降到 2,CRAP 随之降到 6。
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7f31205769
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (heartbeatPendingAnchor() != anchor) { | ||
| NPLogger.d(TAG, "预取结果已过期(reason=$reason),丢弃") | ||
| return@launchBackgroundIo |
There was a problem hiding this comment.
Restart prefetch after direct song switches
When heartbeat is enabled and the user jumps from song A to B through playFromQueue or playPlaylist, the new anchor check correctly discards A's result, but this branch exits without requesting a candidate for B; the only repository call sites request on enable, after a heartbeat append, or after a missed advance. B's next/completion therefore falls back to ordinary song C and only then prefetches for C, so restart selection for the current anchor when a direct switch invalidates the request (or centralize it in the song-transition path).
Useful? React with 👍 / 👎.
| // 否则「失败 → 自动换下一首 → 又选中同一首」会无限循环。 | ||
| _currentSongFlow.value?.let { markHeartbeatSongFailed(it) } | ||
| // 播放失败时先用心动候选顶上(任务书 §3.5 步骤 5),拿不到才按原失败策略推进 | ||
| heartbeatAdvanceOrFallback( |
There was a problem hiding this comment.
Recover with heartbeat before stopping on failure
With repeat mode off, every heartbeat-appended recommendation is the current last queue item, so if it fails resolvePlaybackFailureAdvanceAction returns STOP and dispatchFailureAdvance returns before reaching this recovery call. A single unplayable recommendation therefore stops the stream, ignores an already-prefetched next candidate, and never blacklists the failed song; attempt heartbeat recovery for enabled/supported sources before the terminal stop branch.
Useful? React with 👍 / 👎.
| // 递归调用,放里面会把一次点击记成多次跳过。 | ||
| // 必须在切歌之前取当前歌——切完之后 _currentSongFlow 已指向新歌。 | ||
| // 自动播完推进不走这里,所以「听完」不会被误记成「跳过」。 | ||
| _currentSongFlow.value?.let { recordHeartbeatSkip(it) } |
There was a problem hiding this comment.
Record skips only after Next is accepted
When USB-exclusive loudness protection prompts for confirmation, this records the skip before nextImpl asks for confirmation; if the user cancels, playback never advances but the asynchronous DataStore write still penalizes the current song. The same premature write occurs when another navigation guard rejects Next, so defer feedback recording to the accepted continuation or otherwise confirm that navigation actually proceeds.
Useful? React with 👍 / 👎.
|
|
||
| /** 单次官方请求,失败一律折算成 null(不打断播放链路)。 */ | ||
| private suspend fun fetchOnce(currentSong: SongItem, bypassCache: Boolean): SongItem? = try { | ||
| repository.fetchNextSong(currentSong.id, bypassCache) |
There was a problem hiding this comment.
Keep local IDs out of the NetEase heartbeat endpoint
For a logged-in user playing an imported local file with a positive MediaStore ID, the runtime source guard lets the track through because it only rejects Bilibili and YouTube, and this call sends that unrelated ID to the NetEase endpoint. If it collides with a valid NetEase ID, an unrelated official candidate can replace the local queue; otherwise the user incurs failed network requests before local fallback, so validate NetEase ownership here and return null to let the coordinator use the local source.
Useful? React with 👍 / 👎.
| val platform = song.matchedLyricSource?.name.orEmpty() | ||
| return "$platform:${song.id}" |
There was a problem hiding this comment.
Keep lyric provenance out of song identity
When sourceStableKey is absent, as it is for normal songs produced by NeteaseSongMapping.parseNeteaseSong, this uses matchedLyricSource, which is mutable lyric-provider metadata rather than the playback source. Matching or changing lyrics between Cloud Music and QQ therefore changes the same audio track's heartbeat key, causing anchored prefetches to be discarded and existing dislike, skip, current-song, and queue exclusions to stop matching; derive the fallback from channel/audio identity instead.
Useful? React with 👍 / 👎.
| ?.sorted() | ||
| ?.joinToString("/") | ||
| .orEmpty() | ||
| return if (fromSummary.isNotEmpty()) fromSummary else song.artist.trim().lowercase() |
There was a problem hiding this comment.
Normalize both artist-key representations
When the current NetEase song has neteaseArtists populated but a stats-derived candidate from TrackStat.toHeartbeatSongItem does not, the same artist produces an original-case joined summary on one side and a lowercased fallback on the other (for example Taylor Swift versus taylor swift). Different-album songs from the statistics source are consequently placed in the random tier rather than the same-artist tier, defeating the reason that source was added; apply the same case normalization to the summary branch.
Useful? React with 👍 / 👎.
播放页新增 onHeartbeat 回调与心动提示挂载点后,Kotlin 编译器生成的 lambda 编号整体后移:`NowPlayingScreen$lambda$155$*` 已不存在, `:app:crapReport` 因 "Method scopes have no measured methods" 失败。 按上游 e694ffb 的既有做法,跟随实际编译产物把 method_scopes 里的 11 个 方法名同步为 `lambda$156$1$0$1` 及其 `$22`..`$30` 与 `$24$0`。 仅改质量门禁的名单,不影响任何运行时行为。
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 363ca4833f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| val playlistId = api.likedPlaylistId(userId) | ||
| if (playlistId == null || playlistId <= 0L) { |
There was a problem hiding this comment.
Cache the liked-playlist identity across selections
For a logged-in user playing a stream of different songs, every fetchNextSong cache miss reaches load and repeats both currentUserId() and likedPlaylistId() before the actual recommendation request. The production adapter implements those as getCurrentUserAccount() plus getLikedPlaylistId(), and the latter calls getUserPlaylists(..., limit = 1000), so each prefetch adds two avoidable network round trips and potentially a large playlist payload; cache the account/liked-playlist pair for the login session and invalidate it when the account changes.
Useful? React with 👍 / 👎.
|
|
||
| // 播放失败后的接管不能清零,否则「连续失败」永远攒不到上限,退化成无限重试。 | ||
| if (resetFailureCount) consecutivePlayFailures = 0 | ||
| playAtIndex(index = updatedQueue.currentIndex, commandSource = commandSource) |
There was a problem hiding this comment.
Preserve fade settings for heartbeat advances
When a ready heartbeat candidate is consumed by a manual Next on a normal non-USB route while playback is active, this call leaves useTrackTransitionFade at its default false. The ordinary applyNextTrack path computes queueNavigationTransitionFade() and passes it to playAtIndex, so enabling heartbeat silently bypasses the user's “Next Track Fade” setting and switches tracks abruptly; pass the same transition policy through this path.
Useful? React with 👍 / 👎.
|
|
||
| // 播放失败后的接管不能清零,否则「连续失败」永远攒不到上限,退化成无限重试。 | ||
| if (resetFailureCount) consecutivePlayFailures = 0 | ||
| playAtIndex(index = updatedQueue.currentIndex, commandSource = commandSource) |
There was a problem hiding this comment.
Disable remembered-position resume for heartbeat next tracks
When the selected candidate is a previously partially played track of at least 15 minutes and long-form progress memory is enabled, this call also omits allowRememberedLongFormPosition, so playAtIndex defaults it to true for the usual LOCAL source and starts the recommendation at the stored midpoint. Ordinary user Next and completion navigation explicitly pass false, so heartbeat changes next-track semantics for long-form items; heartbeat advancement should likewise start the candidate at zero.
Useful? React with 👍 / 👎.
| val available = repository != null && | ||
| runCatching { repository.isLoggedIn() }.getOrDefault(false) && | ||
| !isHeartbeatOfficialCircuitOpen() |
There was a problem hiding this comment.
Report local fallback after official selection fails
For a logged-in user whose playmode request returns no result while the local selector succeeds, this calculation remains true because it only checks login state and the circuit breaker. Since failures are counted per current-song key and a successful local fallback advances to a new key, the circuit normally never opens, and NowPlayingHeartbeatFeedback keeps suppressing the local-suggestion disclosure even though every recommendation is local; derive availability from the actual coordinator result or emit an explicit fallback state.
Useful? React with 👍 / 👎.
`isNeteaseExclusiveFeatureAvailable` 里连写两个 `&&`,Kotlin 编译后是两个 独立分支,复杂度 4,零覆盖下 CRAP = 4² + 4 = 20,超过 9 的门禁阈值, ` :app:verifyCrap` 因此失败。 把「非 B 站且非 YouTube」拆到 isNeteaseExclusiveSource:外层只留空值判断, 内层先判 B 站再判 YouTube,两个方法各只有一个分支,复杂度 2,CRAP = 6。 判定语义与调用方完全不变。
- 复用预取候选切歌时补传 useTrackTransitionFade 与 allowRememberedLongFormPosition=false, 与普通「下一首」保持一致,避免绕过淡入淡出设置、避免长音频从记忆位置续播 - 红心歌单 id 按账号缓存,避免每次缓存未命中都重跑 currentUserId() 与 likedPlaylistId()(后者内部会拉一次 limit=1000 的歌单列表) - 官方可用性改为参考上一次选歌的真实来源;新增「正在使用本地推荐」提示文案, 已登录但云端一直不出歌时也能给用户明确反馈
- identityKey 回退到「歌曲 id」时改用播放来源(B 站/网易云/本地)而不是 matchedLyricSource,换歌词服务不再让同一首歌变成另一个身份, 已存的降权、不喜欢与队列排除不会失效 - artistKey 的 neteaseArtists 分支补上下大小写归一,与 artist 回退分支一致, 统计来源的候选才不会因为大小写差异掉进随机层
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e4ee2a49f5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| val tiers = LinkedHashMap<Int, List<HeartbeatCandidate>>() | ||
|
|
||
| if (favorites.isNotEmpty()) { | ||
| tiers[HeartbeatCandidateTier.FAVORITE] = favorites.map { song -> |
There was a problem hiding this comment.
Exclude unsupported sources from local candidates
When local fallback runs and a Bilibili or YouTube song is present in Favorites, this maps it into the highest-priority tier without the source gate used by playback, so it can beat every NetEase candidate and be appended. Once it becomes current, isHeartbeatSourceSupported() refuses further prefetch/advance and the UI hides the heartbeat switch for that source, leaving the stream silently stalled with the mode still ON; filter unsupported-source songs from every local candidate tier.
Useful? React with 👍 / 👎.
| */ | ||
| suspend fun fetchNextSong(currentSongId: Long, bypassCache: Boolean = false): SongItem? { | ||
| if (currentSongId <= 0L) return null | ||
| if (!bypassCache) cachedSong(currentSongId)?.let { return it } |
There was a problem hiding this comment.
Scope cached recommendations to the active account
When the user changes NetEase accounts and requests heartbeat again for the same song within 30 seconds, AppContainer.startCookieObserver() only replaces the client's cookies, while this lookup is keyed solely by song ID and runs before load() reads the current user ID. It can therefore return the previous account's personalized recommendation under the new login; include the account/auth identity in the cache key or invalidate these entries when cookies change.
Useful? React with 👍 / 👎.
`verifyDomainDependencies` 禁止 platform-netease-repository 引用 `kotlin.TuplesKt`,而 Pair 正是编译成该类。改用 userId 与歌单 id 两个独立字段后,playmode 包下已无 TuplesKt 引用。
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4b57bf4984
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } | ||
|
|
||
| private fun playbackSourceKey(song: SongItem): String { | ||
| if (!song.channelId.isNullOrBlank()) return "bili" |
There was a problem hiding this comment.
Branch on the actual playback channel
After the earlier lyric-provenance fix, this new fallback treats every nonblank channelId as Bilibili. NeteaseSongMapping.kt:43 sets normal NetEase items to channelId = "netease", so they receive bili:<id>; when a Bilibili aid matches a NetEase song id, queue/current exclusion and stored skip/dislike feedback treat the two songs as one. Branch on the normalized channel value instead of its mere presence.
Useful? React with 👍 / 👎.
| "startMusicId" to startMusicId.toString(), | ||
| "count" to count.toString(), | ||
| ) | ||
| fun call(usePersistedCookies: Boolean): String = callEApi( |
There was a problem hiding this comment.
Route heartbeat calls through cancellable HTTP
When this suspend call is canceled during a slow response (for example by a caller timeout or scope shutdown), it still runs synchronous callEApi, which reaches blocking newCall(...).execute() at lines 622-624 rather than the existing awaitResponse path. The socket and possible 301 retry therefore continue until the IO timeout after the caller is gone; use requestCancellable with CryptoMode.EAPI for both requests.
Useful? React with 👍 / 👎.
- 身份键改为按 channelId 的取值判断:网易云歌曲的 channelId 是 "netease", 按「有没有值」判断会把它们当成 B 站,导致 bvid 与网易云 id 相同时两边串号。 - 本地候选池每一层都筛掉 B 站/YouTube 歌曲:这类歌被选中播放后,播放侧会拒绝 继续预取、界面又藏起心动入口,用户会看到模式开着却换不了歌。 - 心动接口缓存键加入账号 id:换号后同一首歌不再命中上一个账号的个性化推荐。 - `getPlaymodeIntelligenceListCancellable` 改走 `requestCancellable`: 阻塞调用在调用方取消后仍会等到 IO 超时。
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 64edb11593
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (requestUsbExclusiveLoudPlaybackConfirmation( | ||
| commandSource = commandSource, | ||
| bypassWarning = bypassLoudVolumeWarning, |
There was a problem hiding this comment.
Defer USB confirmation until a next track exists
When playback is paused or stopped on a risky USB-exclusive route and the current item is the final queue entry (force == false) with no ready heartbeat candidate, this preflight displays the loud-playback confirmation before the fallback computes nextStep. Confirming eventually reaches confirmNextTrack and finds a null step, so the user was warned for an operation that cannot start anything; keep this early guard only for a ready heartbeat candidate and leave ordinary navigation's confirmation after its null-step check.
Useful? React with 👍 / 👎.
HeartbeatScorer 为拼身份键需要判断歌曲来自哪个平台,原实现直接读 SongItem.neteaseArtists,触发了 playback-queue 域对 NeteaseArtistSummary 的依赖违规。把判定移到 model 层成为 SongItem.playbackSourceKey(), 播放策略层改为调用它,依赖方向回到允许的方向。
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 07148427a4
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return try { | ||
| val code = JSONObject(resp).optInt("code", -1) | ||
| if (code == 301 && hasLogin()) { | ||
| try { ensureWeapiSession() } catch (_: Exception) {} |
There was a problem hiding this comment.
Keep the 301 session preheat cancellable
When an authenticated heartbeat request receives 301 and is canceled during session warm-up, this branch still calls synchronous ensureWeapiSession() (which reaches requestForSession/executeRequest), so the homepage socket and its IO worker remain occupied until the network timeout after the caller is gone. Although the direct intelligence requests now use requestCancellable, this newly added retry branch needs a cancellable preheat as well.
Useful? React with 👍 / 👎.
| private fun TrackStat.toHeartbeatSongItem(): SongItem = SongItem( | ||
| id = id, | ||
| name = name, | ||
| artist = artist, | ||
| album = album, |
There was a problem hiding this comment.
Preserve custom metadata for statistics candidates
When local fallback must use a supplemental track that exists only in PlaybackStatsRepository (outside Favorites and the recent-history window), this reconstruction copies the raw name, artist, and cover but drops TrackStat.customName, customArtist, and customCoverUrl. Because the playback UI and notification prefer those custom fields, selecting such a recommendation visibly reverts the user's metadata edits even though the statistics row still contains them; copy the custom fields into the reconstructed SongItem.
Useful? React with 👍 / 👎.
带入上游: - fix(ui): preserve page state and back navigation across rotation (cwuom#570) - fix(download): prevent cancelled mobile batches from restoring (cwuom#566) - fix(nowplaying): sync matched favorites and refine song sheets (cwuom#562) - feat(lyrics): add ColorOS 16/17 lockscreen lyrics with Root and Shizuku (cwuom#563) - fix(lyrics): preserve Bluetooth lyrics in display metadata (cwuom#560) - fix(ui): stabilize cover morphs and improve Clean switch contrast (cwuom#561) - fix(netease): isolate rejected songs during playlist sync (cwuom#559) - feat(backup): protect configuration exports and warn on HTTP WebDAV (cwuom#557) - perf(ui): defer expensive UI work and complete Clean theme styling (cwuom#556) - fix(auth): persist accounts when Android Keystore is unavailable (cwuom#554) - fix(lyrics): apply translation mode before publishing songs (cwuom#553) - fix(local): restore scanned audio playback and metadata writes (cwuom#551) 与心动模式改动的重叠文件(NowPlayingScreen、NowPlayingQueueSheet、 MoreOptionsMainContent、PersistedPlayerState、PersistedPlayerStateMapping、 PlayerManager、PlayerManagerPersistenceExtensions、NeteaseClient)均为自动合并, 无冲突;heartbeatMode 持久化字段与恢复调用保持完整。
该判定所属文件在 CRAP 质量门禁的作用域内,此前没有任何测试覆盖, 未覆盖复杂度按 C²+C 计,超过 9 的上限。补上按音源分支的用例, 既锁住「网易云歌曲回落 B 站音源播放时入口照常显示」这条约定, 也让覆盖率抬起来。
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 24d0905869
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| val queue = currentPlaylist.toList() | ||
| PlayerDependencies.launchBackgroundIo { | ||
| val next = try { | ||
| selector.selectNext(currentSong, queue) |
There was a problem hiding this comment.
Cancel selectors before resetting the heartbeat session
When the user turns heartbeat off and back on while a slow selection is still in flight, the generation check runs only after selector.selectNext returns. By then the stale call has already mutated HeartbeatNextTrackCoordinator.failureCounts, officialCircuitOpen, and lastResultFromOfficial, so it can re-add failures after reset() (opening the new session's circuit early) or mislabel a local result as official even though its pending song is discarded; cancel the tracked selection job on a session change or make coordinator state updates generation-aware.
Useful? React with 👍 / 👎.
| (heartbeatSelectorHolder as? HeartbeatNextTrackCoordinator)?.reset() | ||
| // 任务书 §3.6:退出心动模式时让候选池缓存失效, | ||
| // 避免下次开启还沿用上次会话的收藏/历史快照。 | ||
| invalidateHeartbeatCandidateCache() |
There was a problem hiding this comment.
Invalidate local candidates after favorite mutations
When heartbeat remains enabled and a user adds or removes a favorite through updateCurrentFavorite or the queue sheet, this OFF branch is the only production invocation of invalidateHeartbeatCandidateCache; none of those mutation paths calls the hook. The five-minute candidate cache therefore keeps recommending a removed favorite and ignores a newly added one until its TTL expires, so invoke the invalidation hook after successful favorite mutations.
Useful? React with 👍 / 👎.
播放策略层的依赖白名单只允许引用 SongItem 一个模型类,顶层扩展函数会额外 生成 SongPlaybackSourceKt、直接读 neteaseArtists 也会引用 NeteaseArtistSummary, 两者都被判为越界。把这两段判定搬成 SongItem 的成员方法后,调用方字节码里 只剩 SongItem 一个引用,语义不变。
描述 / Description
新增「心动模式」:开启后,一首歌播放完毕(或用户点「下一首」)时,不再按队列顺序播放,而是由应用挑一首「更合当下口味」的歌接上。
选歌有两条路,按登录状态自动切换:
/playmode/intelligence/list),把官方返回的那首歌原样播出去,客户端不做任何二次筛选。同时补齐了四块配套行为:
对既有播放链路零侵入:不改动
PlayerManager的播放服务、Media3/ExoPlayer、音源解析与音频缓存;不新增任何第三方依赖;不新增 HTTP 客户端,全部复用现有NeteaseClient及其 Cookie、鉴权、请求头体系。类型 / Type
修复或解决的问题 / Issues Fixed or Closed by This PR
明确不在本次范围:
count固定为 1,与官方客户端一致);清单 / Checklist
其他信息 / Additional Information
变更动机
原有播放只有「顺序 / 单曲循环 / 随机」三种,用户想让播放器「自己挑下一首」时无处可点。心动模式在保持现有播放链路不变的前提下,只在「该放下一首了」这一个点上接入,因此风险面很小,出问题也能一键关掉回到原行为。
分层结构
UI 层不直接发请求,也不直接算分;平台 JSON 解析集中在
PlaymodeIntelligenceResponseParser,UI 拿不到原始 JSON。新增文件(主源码 20 个)
modules/playback/logic/.../queue/heartbeat/:HeartbeatMode、HeartbeatCandidate、HeartbeatCandidateProvider、HeartbeatCandidateCache、HeartbeatCandidateExhaustionTracker、HeartbeatNextTrackSelector、HeartbeatNextTrackCoordinator、HeartbeatPendingNextStore、HeartbeatScorer、LocalHeartbeatCandidateSourcemodules/playback/runtime/.../core/player/heartbeat/:PlayerManagerHeartbeatExtensions、OfficialHeartbeatSource、LocalHeartbeatCandidateProvidermodules/platform/.../netease/playmode/:NeteasePlaymodeIntelligenceRepository、PlaymodeIntelligenceResponseParsermodules/local/.../data/heartbeat/:DislikedSongRepository、HeartbeatSkipRepositoryapp/src/main/java/...:AppHeartbeatBindings、NowPlayingHeartbeatFeedback资源:
modules/common/src/main/res/drawable/ic_heartbeat_pulse_24.xml关键实现点
官方接口
NeteaseClient.getPlaymodeIntelligenceListCancellable(songId, playlistId, startMusicId, count = 1)type=fromPlayOne,走 eapi 加密接口(/playmode/intelligence/list),与官方客户端一致。PlaymodeIntelligenceResponseParser,结果类型为Success(song)/RequiresLogin/Empty/Failure(code, message),UI 按类型分别处理。likedPlaylistId()(该调用内部会拉一次 limit=1000 的歌单列表)。本地打分(
HeartbeatScorer)候选池排除:当前队列里已有的歌、同一个身份键的歌、以及本次会话内已判定失败(如无法取流)的歌。
歌曲身份键
identityKey:优先用sourceStableKey,其次「播放来源:歌曲 id」(来源只分 B 站 / 网易云 / 本地),最后「名称|歌手」。不看歌词来源——换一个歌词服务不应改变同一首歌的身份,否则已存的降权、不喜欢、队列排除都会失效。队列编辑(
PlayerQueueEditOwner.appendReplacingTail)截掉「当前歌之后」的尾巴后追加,末尾用
selecting(insertedIndex)把播放位置指向新歌。这样队列可以持续增长,而不是把已在队列里的歌反复搬到「下一首」位置。预取与锚点
HeartbeatPendingNextStore,连同发起时的歌曲身份键一起存;消费或写入时都要校验锚点,锚点不符直接丢弃。否则「播 A 时预取的歌」会被用到 B 上。synchronized块内原子发布,避免只改了一半就被读到。切歌策略与普通「下一首」对齐
appendHeartbeatSongAndPlay调playAtIndex时补上useTrackTransitionFade = queueNavigationTransitionFade()与allowRememberedLongFormPosition = false,避免绕过用户的淡入淡出设置、避免长音频从记忆的中点续播。一起听互斥
setHeartbeatMode(ON)时若在一听房间内,直接拒绝并把heartbeatRoomRejectedStateFlow计数 +1。PlayerManager.disableHeartbeatForListenTogetherRoom()在进房时以REMOTE_SYNC来源关掉心动模式,heartbeatRoomAutoOffStateFlow计数 +1。heartbeatRoomAutoOffFlow/heartbeatRoomRejectedFlow/heartbeatUserEnabledFlow。用计数器而不是布尔值,保证同一个事件连发两次也能各提示一次。提示体系
停留时长统一
HeartbeatFailureSnackbarVisibleMs = 3_000L(Snackbar 默认的 Short 约 4 秒,略长且会挡操作)。音源边界
判定入口
isNeteaseExclusiveFeatureAvailable(song)(app/.../nowplaying/NowPlayingSecondaryActions.kt):null视为可用,否则由PlayerManager.isBiliTrack(song)与 YouTube 音源判断决定。判定拆成两层单方法,是因为质量门禁会把&&算成两条独立路径。三处 UI 据此隐藏入口:resolvePlaybackActionToolbarLayout(itemCount = …)按条件取值)。注意判定用的是歌曲自身的逻辑音源,不是实际播放地址:网易云歌曲因无版权回退到 B 站音源播放时,三个入口照常显示——这是刻意的,功能与「歌曲属于哪个平台」绑定,与「音频从哪来」无关。
测试
新增 / 变更单测共 170 例,分布在 13 个单测文件(另有 1 个 androidTest 文件同步更新):
HeartbeatScorerTestLocalHeartbeatCandidateSourceTestPlayerQueueEditOwnerTestNeteasePlaymodeIntelligenceRepositoryTestPlaymodeIntelligenceResponseParserTestHeartbeatPendingNextStoreTestHeartbeatNextTrackSelectorTestHeartbeatCandidateExhaustionTrackerTestHeartbeatSkipRepositoryTestHeartbeatCandidateCacheTestDislikedSongRepositoryTestNowPlayingHeartbeatFeedbackTestNeteaseCryptoPlaymodeEApiUriTest本机实跑结果:
:playback:logic:testDebugUnitTest→ 531 tests,0 failed。:app:testDebugUnitTest --tests *NowPlayingHeartbeatFeedbackTest --tests *OwnedMainSourceLineBudgetTest→ BUILD SUCCESSFUL。:app:compileDebugKotlin(JDK 21)→ BUILD SUCCESSFUL。PYTHONUTF8=1 python -B tools_pub/quality/module_boundaries.py→Module boundaries: passed。:playback:runtime:testDebugUnitTest在本机(Windows)有 5 个失败,已用git stash做对照:无改动状态下同样失败(LocalPlaybackMediaResolverTest2 个 +CarArtworkCacheTest1 个),另 2 个是 Windows 命令行长度限制(CreateProcess error=206)。与本次改动无关。真机验证
在 Redmi 22041211AC / Android 14 / 1440x3200 上逐项验证,日志 TAG
NERI-Heartbeat:setHeartbeatMode: 1, source=LOCAL,预取成功。appendHeartbeatSongAndPlay: song=…, appendIndex=…, queueSize=…,队列从 1 首增长到 9 首持续递增,不再原地循环。disableHeartbeatForListenTogetherRoom: 进入一起听房间,心动模式已关闭;房间内点心动按钮 → 未开启。评审反馈的修复
PR 上自动化评审累计提了 20 条问题,逐条处理如下:
synchronized块内更新bypassLoudVolumeWarning = truebypassCache = truenextImpl,一次切歌发两次选歌请求requestNextTrackresetFailureCount参数化,失败路径传falseTrackStat.identityKey被toHeartbeatSongItem()丢弃,统计键对不上sourceStableKey,统一身份键useTrackTransitionFade,绕过淡入淡出allowRememberedLongFormPosition,长音频从记忆位置续播falsecurrentUserId()+likedPlaylistId()channelId是否存在判断平台,网易云歌曲被当成 B 站netease/bilibili/youtubeMusic/local)requestCancellable另外修了四处质量门禁问题:模块包边界(三个 runtime 侧文件从
queue.heartbeat移到core.player.heartbeat,因为前者在tools_pub/quality/module_boundaries.py里归属:playback:logic)、CRAP 复杂度超标(拆分推进入口与音源判定)、CRAP 方法作用域里 lambda 编号位移、域依赖边界(platform-netease-repository禁止引用kotlin.TuplesKt,避开心动歌单缓存原本用的Pair)。已知取舍
count固定为 1:接口默认值就是 1,count > 1的行为未经验证,属于猜测,不做。行数约束
NowPlayingScreen.kt现有 1979 行,低于仓库 CI 的 2000 行门禁(OwnedMainSourceLineBudgetTest/LocalManagementLineBudget.MAX_EXCLUSIVE = 2_000)。