Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b86a3f2
feat(heartbeat): 心动模式全链路落地
liaolon Oct 19, 2026
ebb144e
feat(heartbeat): 心动模式 UI 归位与任务书阶段三四补齐
liaolon Oct 20, 2026
21bcb60
feat(heartbeat): 补齐设置页心动模式合规说明
liaolon Oct 20, 2026
87e71b5
feat(heartbeat): 换用心形+ECG 图标,失败提示改用原样式停留 1 秒
liaolon Oct 20, 2026
0b8cbf2
feat(heartbeat): 支持标记「不喜欢」,参与心动模式选歌
liaolon Oct 20, 2026
533871a
feat(heartbeat): 记录用户跳过并在选歌时降权
liaolon Oct 21, 2026
a314dad
test(heartbeat): 补跳过降权在评分器侧的断言
liaolon Oct 21, 2026
fd8e3df
feat(heartbeat): 心动流改为截断尾部后追加,并补齐一起听互斥与提示
liaolon Oct 22, 2026
49294bf
feat(ui): B 站与 YouTube 音源下隐藏心动模式、不喜欢、歌曲百科
liaolon Oct 22, 2026
ab6d6d7
fix(heartbeat): 修正模块包边界与评审提出的八处行为缺陷
liaolon Oct 22, 2026
326d396
refactor(heartbeat): 拆分推进入口的分支,避免 CRAP 质量门禁超限
liaolon Oct 22, 2026
c344d13
fix(heartbeat): 锚点与候选原子发布,并丢弃已结束会话的预取结果
liaolon Oct 22, 2026
4a67137
test(nowplaying): 手机横屏辅助动作行补上心动模式按钮
liaolon Oct 22, 2026
7f31205
refactor(heartbeat): 拆分心动推进判断,压回 CRAP 门禁
liaolon Oct 22, 2026
363ca48
fix(quality): 同步 NowPlayingScreen 的 CRAP 方法作用域到新的 lambda 编号
liaolon Oct 23, 2026
fdf7855
fix(quality): 拆分音源判定以降低复杂度到 CRAP 门禁以内
liaolon Oct 24, 2026
05bfc9a
fix(heartbeat): 对齐切歌策略、缓存红心歌单 id、按实际来源修正可用性提示
liaolon Oct 24, 2026
e4ee2a4
fix(heartbeat): 歌曲身份键不再依赖歌词来源,艺人键统一大小写
liaolon Oct 24, 2026
4b57bf4
fix(platform): 红心歌单缓存改用两个 Long 字段,避开域依赖门禁
liaolon Oct 24, 2026
64edb11
fix(heartbeat): 按渠道取值判定音源、候选池筛掉非网易云曲、缓存绑定账号、心动请求走可取消通道
liaolon Oct 24, 2026
0714842
fix(quality): 音源判定下沉到 model 层,解除 playback-queue 域对平台明细类型的依赖
liaolon Oct 25, 2026
2d6baab
merge: 同步 upstream/master(12 个提交)
liaolon Oct 25, 2026
24d0905
test(nowplaying): 覆盖非网易云音源隐藏三入口的判定分支
liaolon Oct 25, 2026
70776cf
refactor(model): 音源与歌手身份键收敛到 SongItem 成员方法,避开域依赖门禁
liaolon Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,17 @@ class NowPlayingPhoneLandscapeActionsTest {
val actions = composeRule.onAllNodes(
hasClickAction() and hasAnyAncestor(hasTestTag("nowPlayingPhoneAuxiliaryActions"))
)
actions.assertCountEquals(5)
repeat(5) { index ->
actions.assertCountEquals(6)
repeat(6) { index ->
val action = actions[index]
if (scrollToActions) action.performScrollTo()
action.assertIsDisplayed().performTouchInput { click(center) }
}
composeRule.runOnIdle {
assertEquals(listOf("queue", "timer", "volume", "lyrics", "add"), clickedActions)
assertEquals(
listOf("queue", "timer", "volume", "lyrics", "add", "heartbeat"),
clickedActions
)
}
}

Expand All @@ -103,7 +106,8 @@ class NowPlayingPhoneLandscapeActionsTest {
onSleepTimer = { clickedActions += "timer" },
onVolume = { clickedActions += "volume" },
onLyrics = { clickedActions += "lyrics" },
onAddToPlaylist = { clickedActions += "add" }
onAddToPlaylist = { clickedActions += "add" },
onHeartbeat = { clickedActions += "heartbeat" }
)
composeRule.setContent {
MaterialTheme {
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,15 @@ import moe.ouom.neriplayer.platform.youtube.auth.YouTubeAuthAutoRefreshManager
import moe.ouom.neriplayer.platform.youtube.auth.YouTubeAuthRepository
import moe.ouom.neriplayer.data.auth.youtube.YouTubeAuthRotationWorker
import moe.ouom.neriplayer.data.model.youtube.auth.YOUTUBE_MUSIC_ORIGIN
import moe.ouom.neriplayer.data.heartbeat.DislikedSongRepository
import moe.ouom.neriplayer.data.heartbeat.HeartbeatSkipRepository
import moe.ouom.neriplayer.data.history.PlayHistoryRepository
import moe.ouom.neriplayer.platform.bilibili.cache.BiliCacheRepositories
import moe.ouom.neriplayer.data.local.platform.bilibili.BiliVideoSkipRepositoryProvider
import moe.ouom.neriplayer.platform.netease.NeteasePlaylistCacheRepository
import moe.ouom.neriplayer.platform.netease.encyclopedia.NeteaseSongEncyclopediaRepository
import moe.ouom.neriplayer.platform.netease.encyclopedia.SongEncyclopediaRepository
import moe.ouom.neriplayer.platform.netease.playmode.NeteasePlaymodeIntelligenceRepository
import moe.ouom.neriplayer.platform.youtube.playlist.YouTubeMusicPlaylistCacheRepository
import moe.ouom.neriplayer.data.playlist.usage.LocalPlaylistPlaybackStatsRepository
import moe.ouom.neriplayer.data.playlist.usage.PlaylistUsageRepository
Expand Down Expand Up @@ -283,6 +286,14 @@ object AppContainer {
val localPlaylistPlaybackStatsRepo by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
LocalPlaylistPlaybackStatsRepository.getInstance(application)
}
/** 心动模式「不喜欢」名单(本地 DataStore,无官方接口可同步)。 */
val dislikedSongRepo by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
DislikedSongRepository(application)
}
/** 心动模式「跳过」次数(本地 DataStore,只并入重复惩罚,不做硬排除)。 */
val heartbeatSkipRepo by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
HeartbeatSkipRepository(application)
}
val biliFavoriteFolderCacheRepo by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
BiliCacheRepositories.createFavoriteFolder(application)
}
Expand Down Expand Up @@ -380,6 +391,11 @@ object AppContainer {
NeteaseSongEncyclopediaRepository(neteaseClient)
}

/** 心动模式官方推荐仓库;未登录或接口不可用时由本地降级接管。 */
val heartbeatOfficialRepository: NeteasePlaymodeIntelligenceRepository by lazy {
NeteasePlaymodeIntelligenceRepository(neteaseClient)
}

private val commentCache = CommentMemoryCache()
private val neteaseComments = NeteaseCommentRepository(commentCache) { neteaseClient }
private val biliComments = BiliCommentRepository(commentCache) { biliClient }
Expand Down Expand Up @@ -522,6 +538,7 @@ object AppContainer {
initialized = true
moe.ouom.neriplayer.core.integration.download.installDownloadDependencies()
moe.ouom.neriplayer.core.di.player.installPlayerDependencies(app)
moe.ouom.neriplayer.core.di.player.installHeartbeatDependencies(app)
moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess.bind(
downloads = moe.ouom.neriplayer.core.download.host.media.AndroidLocalMediaDownloads,
covers = moe.ouom.neriplayer.core.download.host.media.AndroidLocalMediaCovers,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package moe.ouom.neriplayer.core.di.player

import android.app.Application
import moe.ouom.neriplayer.core.di.AppContainer
import moe.ouom.neriplayer.core.player.queue.heartbeat.HeartbeatCandidateCache
import moe.ouom.neriplayer.core.player.queue.heartbeat.HeartbeatNextTrackCoordinator
import moe.ouom.neriplayer.core.player.heartbeat.LocalHeartbeatCandidateProvider
import moe.ouom.neriplayer.core.player.queue.heartbeat.LocalHeartbeatCandidateSource
import moe.ouom.neriplayer.core.player.heartbeat.OfficialHeartbeatSource
import moe.ouom.neriplayer.core.player.heartbeat.installHeartbeatOfficialRepository
import moe.ouom.neriplayer.core.player.heartbeat.installHeartbeatSelector
import moe.ouom.neriplayer.core.player.heartbeat.installHeartbeatSkipRecorder
import moe.ouom.neriplayer.data.history.PlayHistoryRepository
import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository

/**
* 心动模式依赖接线。
*
* 分层约束(心动模式任务书 §3.2):`playback/logic` 只认识
* [moe.ouom.neriplayer.core.player.queue.heartbeat.HeartbeatNextTrackSelector] 抽象,
* 官方实现与本地降级都在 `playback/runtime`,由本文件在 `app` 侧组装后一次性注入,
* 从而避免 `logic` 反向依赖 `platform` / `local`。
*/
internal fun installHeartbeatDependencies(application: Application) {
// 「不喜欢」名单:网易云没有对应接口,只能本地存;官方源与本地源共用这一份。
val dislikedSongRepository = AppContainer.dislikedSongRepo
// 跳过次数:任务书 §七 第三阶段「记录用户跳过行为」,只并入重复惩罚,不做硬排除。
val skipRepository = AppContainer.heartbeatSkipRepo
installHeartbeatSkipRecorder { identityKey -> skipRepository.recordSkip(identityKey) }

// 官方源:网易云心动接口。未登录或接口不可用时它自行返回 null,交棒给本地降级。
val officialRepository = AppContainer.heartbeatOfficialRepository
installHeartbeatOfficialRepository(officialRepository)
val officialSource = OfficialHeartbeatSource(
repository = officialRepository,
dislikedSongRepository = dislikedSongRepository,
)

// 本地降级源:收藏 + 历史 + 统计库(层级 3/4 原料与播放次数)。
val candidateProvider = LocalHeartbeatCandidateProvider(
playHistoryRepository = PlayHistoryRepository.getInstance(application),
localPlaylistRepository = LocalPlaylistRepository.getInstance(application),
context = application,
playbackStatsRepository = AppContainer.playbackStatsRepo,
dislikedSongRepository = dislikedSongRepository,
heartbeatSkipRepository = skipRepository,
)
val localSource = LocalHeartbeatCandidateSource(
provider = candidateProvider,
cache = HeartbeatCandidateCache(),
)

// 官方优先、本地兜底的组合选择器,同时负责任务书要求的失败熔断。
installHeartbeatSelector(
HeartbeatNextTrackCoordinator(
officialSource = officialSource,
localSource = localSource,
)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/*
* NeriPlayer - A unified Android player for streaming music and videos from multiple online platforms.
* Copyright (C) 2025-2025 NeriPlayer developers
* https://github.com/cwuom/NeriPlayer
*
* This software is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this software.
* If not, see <https://www.gnu.org/licenses/>.
*
* File: moe.ouom.neriplayer.ui.screen.nowplaying/NowPlayingHeartbeatFeedback
*/

package moe.ouom.neriplayer.ui.screen.nowplaying

import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import moe.ouom.neriplayer.common.R as CoreCommonR
import moe.ouom.neriplayer.core.player.PlayerManager
import moe.ouom.neriplayer.core.player.heartbeat.heartbeatCandidateExhaustedFlow
import moe.ouom.neriplayer.core.player.heartbeat.heartbeatOfficialAvailableFlow
import moe.ouom.neriplayer.core.player.heartbeat.heartbeatRoomAutoOffFlow
import moe.ouom.neriplayer.core.player.heartbeat.heartbeatRoomRejectedFlow
import moe.ouom.neriplayer.core.player.heartbeat.heartbeatUserEnabledFlow
import moe.ouom.neriplayer.ui.feedback.dismissCurrentNeriSnackbar
import moe.ouom.neriplayer.ui.feedback.showNeriSnackbar

/** 提示的停留时长。比 Snackbar 默认的 Short(约 4 秒)略短,够看清又不挡操作。 */
private const val HeartbeatFailureSnackbarVisibleMs = 3_000L

/**
* 心动模式的底部提示。
*
* 三类事件各一条,都只停留 3 秒(Snackbar 默认的 Short 约 4 秒,略长且会挡操作):
*
* 1. **候选耗尽**——连续多次选不出歌、播放器已回退原有模式。这是功能真没帮上忙的时刻。
* 官方接口临时失败不算:那会静默降级到本地推荐,心动模式仍在工作。
* 2. **进一起听房间被自动关闭**——房间里的播放由房间控制,心动模式选歌不会生效,
* 必须让用户知道开关被动了,否则会以为是自己误触。
* 3. **房间里想开启被拒绝**——点了没反应最难受,明确告诉他为什么。
*
* 另外还有一条**成功**提示,是全项目唯一的例外:未登录(或官方接口不可用)时
* 用户主动开启心动模式,会额外说明「登录后可使用云端推荐」,见 [shouldShowHeartbeatLocalHint]。
* 之所以破例,是因为「现在用的是本地推荐」这件事用户无法从界面其它地方得知,
* 而它直接决定推荐质量;其余成功场景一律不提示,状态恢复也不算「用户主动开启」。
*
* 样式沿用项目原有的 Snackbar(`NeriOverlaySnackbarHost` 渲染在播放页底部),
* 做法是照常 `showNeriSnackbar`,到点再主动 `dismiss`,不引入新的提示组件。
*
* 为什么单独成文件而不是塞进 `NowPlayingScreen.kt`:播放页已有 1900+ 物理行,
* 而 `OwnedMainSourceLineBudgetTest` 把该文件卡在 2000 行以内(见
* `LocalManagementLineBudgetTest.MAX_EXCLUSIVE`)。反馈逻辑放在这里,
* 播放页只留一行调用。
*/
@Composable
fun NowPlayingHeartbeatFeedback(
snackbarHostState: SnackbarHostState,
scope: CoroutineScope,
) {
val heartbeatCandidatesExhausted by PlayerManager
.heartbeatCandidateExhaustedFlow
.collectAsStateWithLifecycle()
val messageCandidatesExhausted = stringResource(
CoreCommonR.string.heartbeat_candidates_insufficient
)

// 首次组合一律跳过:只对「本次会话内新发生的」耗尽上升沿给反馈,
// 避免恢复状态时把上一次会话遗留的耗尽值弹出来。
//
// 注意这里的顺序:先判首组合再判状态值。写成「先判状态值再判首组合」会让
// 初始值 false 时永远走不到首组合分支,导致**第一次**真实耗尽被吞掉、
// 要第二次才提示(旧实现的 bug)。
var isFirstComposition: Boolean by remember { mutableStateOf(true) }
LaunchedEffect(heartbeatCandidatesExhausted) {
if (isFirstComposition) {
isFirstComposition = false
return@LaunchedEffect
}
if (!heartbeatCandidatesExhausted) return@LaunchedEffect
scope.launch {
// 提示本身是挂起调用(会一直等到 Snackbar 消失),所以放到子协程里,
// 让下面的计时器能按时把它收掉。
launch { snackbarHostState.showNeriSnackbar(message = messageCandidatesExhausted) }
delay(HeartbeatFailureSnackbarVisibleMs)
snackbarHostState.dismissCurrentNeriSnackbar()
}
}

val heartbeatRoomAutoOff by PlayerManager.heartbeatRoomAutoOffFlow.collectAsStateWithLifecycle()
val heartbeatRoomRejected by PlayerManager.heartbeatRoomRejectedFlow.collectAsStateWithLifecycle()
val messageRoomAutoOff = stringResource(CoreCommonR.string.heartbeat_room_auto_disabled)
val messageRoomRejected = stringResource(CoreCommonR.string.heartbeat_room_cannot_enable)

// 两个计数器都只增不减,所以「数值变了」就等于「又发生了一次」。
// 首组合同样跳过:计数器会随进程状态恢复而带上历史值,那不是本次发生的事。
var isFirstRoomEvent: Boolean by remember { mutableStateOf(true) }
var lastRoomAutoOff: Int by remember { mutableStateOf(0) }
var lastRoomRejected: Int by remember { mutableStateOf(0) }
LaunchedEffect(heartbeatRoomAutoOff, heartbeatRoomRejected) {
if (isFirstRoomEvent) {
isFirstRoomEvent = false
lastRoomAutoOff = heartbeatRoomAutoOff
lastRoomRejected = heartbeatRoomRejected
return@LaunchedEffect
}
// 两个计数器可能同时变化,优先说「被拒绝」:那是用户刚做的操作,更该被解释。
val message = when {
heartbeatRoomRejected > lastRoomRejected -> messageRoomRejected
heartbeatRoomAutoOff > lastRoomAutoOff -> messageRoomAutoOff
else -> return@LaunchedEffect
}
lastRoomAutoOff = heartbeatRoomAutoOff
lastRoomRejected = heartbeatRoomRejected
scope.launch {
launch { snackbarHostState.showNeriSnackbar(message = message) }
delay(HeartbeatFailureSnackbarVisibleMs)
snackbarHostState.dismissCurrentNeriSnackbar()
}
}

// 全项目唯一的「成功也提示」:用户主动开启、且当前走的是本地推荐时,
// 顺带告诉他登录后能换成云端推荐。其余成功场景一律不提示。
val heartbeatUserEnabled by PlayerManager.heartbeatUserEnabledFlow.collectAsStateWithLifecycle()
val heartbeatOfficialAvailable by PlayerManager.heartbeatOfficialAvailableFlow
.collectAsStateWithLifecycle()
val messageEnabledLocalHint = stringResource(CoreCommonR.string.heartbeat_mode_enabled_local_hint)
val messageOfficialUnavailable = stringResource(
CoreCommonR.string.heartbeat_official_unavailable_hint
)

var isFirstUserEnable: Boolean by remember { mutableStateOf(true) }
var lastUserEnabled: Int by remember { mutableStateOf(0) }
var lastOfficialAvailable: Boolean by remember { mutableStateOf(true) }
LaunchedEffect(heartbeatUserEnabled, heartbeatOfficialAvailable) {
if (isFirstUserEnable) {
isFirstUserEnable = false
lastUserEnabled = heartbeatUserEnabled
lastOfficialAvailable = heartbeatOfficialAvailable
return@LaunchedEffect
}
val userJustEnabled = heartbeatUserEnabled > lastUserEnabled
// 开启那一刻还不知道实际来源:登录态在、熔断也没开,就先报「可用」。
// 等第一次选歌回来才发现官方没出歌时,可用性会掉成 false——这种
// 「开了之后才发现在走本地」也要说一声,否则用户以为用着云端推荐。
val fellBackToLocal = lastOfficialAvailable && !heartbeatOfficialAvailable
lastUserEnabled = heartbeatUserEnabled
lastOfficialAvailable = heartbeatOfficialAvailable
if (!userJustEnabled && !fellBackToLocal) return@LaunchedEffect
if (!shouldShowHeartbeatLocalHint(officialAvailable = heartbeatOfficialAvailable)) {
return@LaunchedEffect
}
val message = if (userJustEnabled) messageEnabledLocalHint else messageOfficialUnavailable
scope.launch {
launch { snackbarHostState.showNeriSnackbar(message = message) }
delay(HeartbeatFailureSnackbarVisibleMs)
snackbarHostState.dismissCurrentNeriSnackbar()
}
}
}

/**
* 开启心动模式后要不要提示「当前是本地推荐」。
*
* 只在官方接口不可用时提示:官方可用时用户得到的就是他期待的效果,不需要解释。
* 这个函数是任务书「本地降级不得表述为官方推荐」这条合规要求的唯一判定点,
* 因此单独存在、单独被测试覆盖。
*/
internal fun shouldShowHeartbeatLocalHint(officialAvailable: Boolean): Boolean = !officialAvailable

/**
* 心动模式开关的文案判定(任务书 §四 步骤 2 文案表)。
*
* 注意:界面上实际使用的是 [shouldShowHeartbeatLocalHint] + `heartbeat_mode_enabled_local_hint`
* 这条更完整的文案,本函数**当前不被界面调用**。保留它和
* [moe.ouom.neriplayer.ui.screen.nowplaying.NowPlayingHeartbeatFeedbackTest] 的原因:
* 它记录了「开启/关闭 × 官方可用/本地」这张完整文案表的映射关系,
* 将来若要区分「关闭」提示可直接接回去,不必重新推导。
*
* 本地降级状态必须带「(本地推荐)」后缀:官方接口失败/未登录时用户需要知道推荐来源已变,
* 但文案绝不提「官方推荐」「网易云心动算法」之类字样,只用中性表述。
*/
internal fun resolveHeartbeatSnackbarMessage(
heartbeatEnabled: Boolean,
officialAvailable: Boolean,
messageEnabled: String,
messageEnabledLocal: String,
messageDisabled: String
): String = when {
heartbeatEnabled && officialAvailable -> messageEnabled
heartbeatEnabled -> messageEnabledLocal
else -> messageDisabled
}
Loading
Loading