Skip to content

feat: native player#97

Merged
roitium merged 26 commits into
devfrom
native-player
Dec 13, 2025
Merged

feat: native player#97
roitium merged 26 commits into
devfrom
native-player

Conversation

@roitium
Copy link
Copy Markdown
Collaborator

@roitium roitium commented Dec 5, 2025

Closes #85

Summary by CodeRabbit

  • New Features

    • 切换至云端播放与同步后端(播放、队列、下载与设置云化);设置页开关即时生效,下载页改为异步加载并显示加载/错误状态;预览构建任务加入。
  • Bug Fixes

    • 改进播放错误提示与结束处理:播放异常提示更友好、曲目结束时持久化进度并记录历史,当前曲目检测与切换更稳健。
  • Chores

    • 精简本地下载相关数据结构;应用版本号更新为 2.1.1。

✏️ Tip: You can customize this high-level summary in your review settings.

@safedep
Copy link
Copy Markdown

safedep Bot commented Dec 5, 2025

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

Package Details
Package Malware Vulnerability Risky License Report
icon @rnrepo/expo-config-plugin @ 0.1.0-beta.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon @roitium/expo-orpheus @ 0.5.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗
icon react-native-logs @ 5.5.0
pnpm-lock.yaml
ok icon
ok icon
ok icon
🔗

This report is generated by SafeDep Github App

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Dec 5, 2025

Walkthrough

将播放器与下载子系统从 react-native-track-player/本地实现迁移至原生 Orpheus:移除旧的播放器、下载服务与相关 schema,重写大量 Hook、工具与组件以使用 Orpheus API 与 react-query,同时新增若干迁移与构建配置。(≤50 字)

Changes

内聚块 / 文件(s) 变更摘要
入口与依赖
\index.js`, `package.json`, `README.md``
移除 TrackPlayer 注册,替换依赖为 @roitium/expo-orpheus,更新版本及依赖源,README 播放库说明更新。
启动 / AppStore 与 Cookie 注入
\src/app/_layout.tsx`, `src/hooks/stores/useAppStore.ts``
启动时序列化 bilibili cookie 并通过 Orpheus 注入;移除本地持久播放位置设置,AppStore 与 Orpheus 同步逻辑新增。
删除旧播放器实现
\src/hooks/stores/usePlayerStore.ts`, `src/lib/player/playerLogic.ts`, `src/lib/player/playbackService.ts`, `src/types/core/playerStore.ts``
完全删除 Zustand 播放器、playerLogic、PlaybackService 与相关类型/接口(队列、控制、持久化逻辑移除)。
Orpheus 集成 — Hooks / 进度 / 事件
\src/hooks/player/`, `src/lib/player/progressListener.ts`, `src/features/player/hooks/``
新增/重写 useCurrentTrackuseIsCurrentTrack、use*Progress 等 Hook,进度/缓冲/seek 等调用由 TrackPlayer 切换为 Orpheus(getPosition/getDuration/getBuffered/seekTo),并以 Orpheus 事件替代原监听。
队列与工具函数重写
\src/utils/player.ts`, `src/components/modals/PlayerQueueModal.tsx``, 多处 playlist 文件
新增/重命名工具(convertToOrpheusTrack、getInternalPlayUri、addToQueue、finalizeAndRecordCurrentTrack 等),队列 UI 改为通过 Orpheus/react-query 获取并以 index 操作;enqueue/播放逻辑迁移。
播放控件与 UI 调整
\src/features/player/components/*`, `src/components/NowPlayingBar.tsx`, `src/app/player.tsx`, `src/components/modals/player/SleepTimerModal.tsx``
所有播放控制(play/pause/skip/shuffle/repeat/seek)调用改为 Orpheus;睡眠定时器与下载状态改用 Orpheus/react-query 驱动,图标/状态依据 Orpheus 数据。
下载子系统重构
\src/lib/services/downloadService.ts`(删除),`src/hooks/stores/useDownloadManagerStore.ts`, `src/features/downloads/`, `src/lib/db/schema.ts`, drizzle/`
删除 DownloadService 与 track_downloads 表及相关 types;引入 Orpheus 下载事件映射与轻量事件发射器(eventListner);组件改为使用 Orpheus DownloadState 与 react-query;新增删除表的 migration。
大量组件/页面 Hook 替换
\src/app/(tabs)/settings.tsx`, `src/app/download.tsx`, `src/app/leaderboard.tsx`, `src/app/test.tsx`, `src/features/library/`, `src/features/playlist/``, 等
将 haveTrack/current-track 的来源从 usePlayerStore selector 改为 useCurrentTrack()/useIsCurrentTrack() 或 Orpheus query;下载/播放相关调用切换到 Orpheus,UI 间距与 render paths 调整。
本地播放列表与菜单/播放流调整
\src/features/playlist/local/*`, `src/app/playlist/local/[id].tsx``
playAll/useLocalPlaylistPlayer 改为以 playlistId 拉取 tracks,再通过 convertToOrpheusTrack/addToQueue/Orpheus 执行播放或下载;移除 playlistContents prop。
类型与导出变更
\src/types/core/media.ts`, `src/types/rntp.ts`, `src/types/core/appStore.ts`, `src/types/core/playerStore.ts``
移除 RNTPTrack、TrackDownloadRecord、playerStore 类型以及 appStore 中的 enablePersistCurrentPosition 等字段,调整导出以匹配新实现。
迁移 & 构建配置
\drizzle/0012_blushing_human_fly.sql`, `drizzle/meta/*`, `drizzle/migrations.js`, `mise.toml`, `app.config.ts``
新增删除 track_downloads 的 migration 与 snapshot/journal,加入 preview 构建任务,修改 Android minify/shrink 与插件配置。
若干组件签名调整
\src/features/downloads/DownloadTaskItem.tsx`, `src/components/modals/PlayerQueueModal.tsx`, `src/features/playlist/local/components/LocalPlaylistItem.tsx``, 等
一些组件 prop/签名变更(如 DownloadTaskItem.taskinitTask,PlayerQueueModal 改以 index 操作,LocalPlaylistItem 新增 downloadState 等)。

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant UI as 前端 UI
    participant JS as JS 层 (hooks/utils)
    participant Orpheus as Orpheus 原生模块
    participant DB as 本地 DB / trackService

    Note over UI,JS: 用户触发播放或队列操作
    UI->>JS: 调用 addToQueue / convertToOrpheusTrack / playNext
    JS->>DB: 可选:findOrCreateTrack (track metadata)
    DB-->>JS: 返回 track details
    JS->>Orpheus: Orpheus.addToQueue / Orpheus.play / Orpheus.playNext
    Orpheus-->>JS: 触发 onTrackStarted/onPositionUpdate/onTrackFinished 事件
    JS->>UI: Hooks / react-query 更新 -> 界面重渲染(NowPlaying、Queue、进度)
    Note over Orpheus,JS: 播放期间进度与缓冲更新
    Orpheus-->>JS: onPositionUpdate(position,duration,buffered)
    JS->>UI: 更新进度条与歌词同步(useLyricSync/useTrackProgress)
    Orpheus->>JS: onTrackFinished(trackId,duration,finalPosition)
    JS->>DB: finalizeAndRecordCurrentTrack(trackId,duration,finalPosition)
    DB-->>JS: 写入完成 -> Hooks/Queries invalidate/refetch
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

重点复核项:

  • src/hooks/stores/useAppStore.ts 与 src/app/_layout.tsx:cookie 序列化、Orpheus 注入及失败回退与 Sentry 上报。
  • src/utils/player.ts:convertToOrpheusTrack、addToQueue、finalizeAndRecordCurrentTrack 的契约、playNext/playNow/clearQueue 行为及边界条件。
  • 数据库迁移与 schema 删除:drizzle/0012_blushing_human_fly.sql、src/lib/db/schema.ts,确认迁移影响与回滚路径。
  • 被删除的核心模块(usePlayerStore、playerLogic、downloadService):确保全局无残留调用或类型依赖。
  • 事件订阅/释放与 react-query:Orpheus 订阅生命周期、refetch 时机与组件可见性依赖(如 PlayerQueueModal、SleepTimerModal)。

Possibly related PRs

  • feat: implement audio doanload #36 — 与下载子系统直接相关(DownloadService、track_downloads 表、下载组件变动),可能有冲突。
  • feat: persist current position #90 — 涉及播放器初始化、进度监听与持久化,和将 TrackPlayer 替换为 Orpheus 的改动高度相关。
  • feat: sleep timer #65 — 睡眠定时器从本地 store 转为 Orpheus/react-query,变更点与本 PR 在 SleepTimerModal 和定时器逻辑上有关联。

Poem

🐰 我是小兔子,耳朵听新曲,
把旧轨道卸下,Orpheus 敲新鼓。
Cookie 串成线,队列交原生手,
事件跳又舞,进度细心记牢。
兔子鼓掌:新旅程,轻盈向前跑。

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.24% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题"feat: native player"准确概括了PR的主要变更——将播放器实现从react-native-track-player迁移到原生Orpheus库。
Linked Issues check ✅ Passed PR完成了#85中的核心编码需求:移除react-native-track-player依赖、集成Orpheus库、将播放器逻辑从usePlayerStore迁移到原生层、实现基础队列和播放控制功能。
Out of Scope Changes check ✅ Passed 所有变更均与#85目标相关:替换播放库、迁移播放器状态管理、集成新库、移除旧存储逻辑。包括删除track_downloads表反映了架构重组,符合原生层播放器实现的目标。
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch native-player

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@roitium roitium added the enhancement New feature or request label Dec 5, 2025
@roitium roitium marked this pull request as draft December 5, 2025 14:26
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app/_layout.tsx (1)

96-117: Orpheus.setBilibiliCookie() must be awaited, and global.playerIsReady is never set to true.

Line 104 calls Orpheus.setBilibiliCookie() without await. Throughout the codebase, all other Orpheus methods are awaited (e.g., await Orpheus.clear(), await Orpheus.play(), await Orpheus.seekTo()), which means this call is async. Without await, errors in setBilibiliCookie() bypass the try-catch block and cause unhandled promise rejections.

Additionally, global.playerIsReady is never set to true on success, rendering the guard condition on line 97 ineffective. Every app start will re-execute the initialization logic.

Suggested fix:

-const initializePlayer = () => {
+const initializePlayer = async () => {
 if (!global.playerIsReady) {
   try {
     const cookie = useAppStore.getState().bilibiliCookie
     if (!cookie) {
       logger.info('没有 bilibili cookie,跳过播放器初始化')
       return
     }
-    Orpheus.setBilibiliCookie(serializeCookieObject(cookie))
+    await Orpheus.setBilibiliCookie(serializeCookieObject(cookie))
+    global.playerIsReady = true
   } catch (error) {
     logger.error('播放器初始化失败: ', error)
     reportErrorToSentry(
       error,
       '播放器初始化失败',
       ProjectScope.Player,
     )
     global.playerIsReady = false
   }
 }
}

-void initializePlayer()
+void initializePlayer()
♻️ Duplicate comments (2)
src/features/playlist/remote/toview/components/Item.tsx (1)

3-3: ToView 列表中同样存在每项各自订阅当前曲目的性能隐患

这里的 useIsCurrentTrack(data.uniqueKey) 与普通播放列表项实现一致,会为每个 ToViewTrackListItem 分别订阅 Orpheus 事件并调用 getCurrentTrack。在分 P 视频列表较长时,同样会放大切歌时的 native 调用次数。

可以和普通 PlaylistItem 一并考虑:

  • 在列表级别维护一次当前曲目,然后在 item 里比较 uniqueKey
  • 或让 useIsCurrentTrack 基于共享的当前曲目状态实现。

功能上没问题,只是后续有精力时可以做这类小优化。

Also applies to: 66-69

src/features/library/local/LocalPlaylistList.tsx (1)

3-3: 本地歌单列表中 haveTrack 的用法与多页列表类似,建议统一成更语义化的写法

const haveTrack = useCurrentTrack()
// ...
contentContainerStyle={{ paddingBottom: haveTrack ? 70 : 10 }}

这里同样是用“当前曲目对象是否存在”来控制底部留白,行为正确。但变量名与类型略不匹配,和其它使用 useCurrentTrack 的组件相比也不够直观。

建议与其它页面统一,例如:

const currentTrack = useCurrentTrack()
// ...
contentContainerStyle={{ paddingBottom: currentTrack ? 70 : 10 }}

或者抽成 const hasTrack = !!useCurrentTrack(),便于一眼看出是布尔语义。

Also applies to: 21-23, 100-102

🧹 Nitpick comments (18)
src/lib/player/progressListener.ts (1)

2-19: Orpheus 进度事件接入整体 OK,可考虑增强健壮性

整体实现看起来没问题:在模块级别注册 onPositionUpdate 监听,并通过 playerProgressEmitter 以 sticky 事件形式广播,结构也与 Events['progress'] 对齐,同时补充了 buffered 字段,这对现有依赖这个 emitter 的 Hook 来说应该是兼容增强。

有两个可以考虑的细节优化:

  1. buffered 的兜底处理(健壮性)
    取决于 @roitium/expo-orpheus 的事件定义,如果 e.buffered 可能为 undefinednull,下游在做进度百分比计算时容易得到 NaN。可以用空值合并做个兜底,例如:

    Orpheus.addListener('onPositionUpdate', (e) => {
    	playerProgressEmitter.emitSticky('progress', {
    		position: e.position,
    		duration: e.duration,
  •   buffered: e.buffered,
    
  •   buffered: e.buffered ?? 0,
    
    })
    })
    
    
  1. 事件名与 payload 字段的 API 校验(类型/文档一致性)
    • 建议确认 'onPositionUpdate' 是否为 Orpheus 官方暴露的事件名,以及 position/duration/buffered 三个字段名与类型与文档保持一致。
    • 如果包里有类型定义(例如事件 Map 类型),可以给 e 标注具体类型,避免后续 Orpheus 升级时静默 break。

这些都属于可选增强,不影响当前合并,但后续有时间可以顺手补一下。

src/components/modals/player/SleepTimerModal.tsx (1)

38-41: handleSetTimer 仅关闭弹窗,自定义输入仍做“伪校验”,行为略迷惑

现在:

  • handleSetTimer = (_minutes: number) => { close('SleepTimer') },参数完全未使用;
  • 预设按钮点击时依然传入 minutes 调用 handleSetTimer(minutes)
  • 自定义输入中依然解析 customMinutes、校验 > 0,但校验通过后同样只是调用 handleSetTimer(minutes) → 关闭弹窗。

对用户而言:

  • 选择/输入时间后没有任何定时效果,只是弹窗关闭;
  • 对非法输入(空字符串、0、负数等),“设置”按钮静默无反应,看起来像是“点了没反应”的 Bug。

在当前 stub 阶段可以考虑:

  • 要么在“设置”按钮点击时直接调用 close('SleepTimer'),去掉对 minutes 的解析和条件判断,降低迷惑性;
  • 要么保留解析逻辑,但在无实际定时行为的情况下用文案/Toast 更明确说明“当前不会真正生效”,避免给用户“已设置成功”的错觉;
  • 如果是刻意保留接口形状,为后续接回真正的 setSleepTimer,可以在函数上加一行 TODO 注释(例如 // TODO: 接 Orpheus 实现 sleep timer,恢复使用 minutes 参数)以提醒后续开发者。

属于体验和可读性问题,不是阻塞性 bug,但建议在确定产品策略后顺手整理一下。

Also applies to: 62-73, 74-96

src/components/modals/PlayerQueueModal.tsx (1)

153-164: 建议:renderItem 可能导致不必要的重渲染。

renderItem 回调依赖于 switchTrackHandlerremoveTrackHandler,而这两个处理器在 queuerefetch 变化时会重新创建。这可能导致整个列表重渲染。

考虑到 TrackItem 已使用 memo 包裹,当前实现应该可以接受,但如果遇到性能问题,可以考虑将处理器稳定化。

src/features/playlist/remote/components/PlaylistItem.tsx (1)

2-2: 在每个列表项中直接使用 useIsCurrentTrack 可能带来多重订阅与性能开销

useIsCurrentTrack(data.uniqueKey) 会在每个 TrackListItem 实例中创建一个 Orpheus 监听和一次初始 getCurrentTrack 调用。对于包含大量歌曲的播放列表,这意味着一次切歌会触发 N 次 native 调用和状态更新。

建议后续考虑:

  • 在更高层(例如列表容器)用一个 useCurrentTrack 或共享 context 维护当前曲目,再在 item 内做简单比较;或
  • 重构 useIsCurrentTrack,让它复用全局的当前曲目状态,而不是每次自己订阅 onTrackTransition

这样可以保持现有高亮逻辑不变,同时避免列表规模增大时潜在的性能/功耗问题。

Also applies to: 61-65

src/features/playlist/local/components/LocalPlaylistHeader.tsx (1)

183-189: onPress={() => onClickPlayAll()} 可以简化为直接传入回调

这里外包一层箭头函数在行为上与 onPress={onClickPlayAll} 等价,但会在每次渲染创建一个新函数,且当前签名中也不需要额外参数。

如果没有特殊原因(例如需要显式捕获额外闭包变量),建议改回:

<Button
  mode='contained'
  icon='play'
  onPress={onClickPlayAll}
>

以减少不必要的函数创建并提升可读性。

src/app/(tabs)/settings.tsx (1)

4-4: useCurrentTrack 作为有无曲目的判断是合理的,但命名可以更语义化

目前:

const haveTrack = useCurrentTrack()
// ...
paddingBottom: haveTrack ? 70 : insets.bottom

useCurrentTrack 返回 Track | nullhaveTrack 实际上是“当前曲目对象”,而不是布尔值。运行上没问题,但阅读时容易误解类型。

可以考虑两种微调(选其一即可):

  • 显式转成布尔:

    const hasTrack = !!useCurrentTrack()
    ...
    paddingBottom: hasTrack ? 70 : insets.bottom
  • 或者保留对象,但改名更贴近含义,例如:

    const currentTrack = useCurrentTrack()
    ...
    paddingBottom: currentTrack ? 70 : insets.bottom

纯可读性建议,不影响现有行为。

Also applies to: 35-46

src/features/library/multipage/MultiPageVideosList.tsx (1)

4-4: 使用 useCurrentTrack 控制列表底部内边距是合理的,命名可再明确一点

const haveTrack = useCurrentTrack()
// ...
contentContainerStyle={{ paddingBottom: haveTrack ? 70 : 10 }}

逻辑上“有当前曲目就多留出 70 像素”的意图是清晰的,不过:

  • haveTrack 实际类型是 Track | null,语义更接近“当前曲目对象”;
  • 后续维护者可能会误以为这是布尔值。

可以考虑改为:

const currentTrack = useCurrentTrack()
const paddingBottom = currentTrack ? 70 : 10
// ...
contentContainerStyle={{ paddingBottom }}

或者直接 const hasTrack = !!useCurrentTrack(),按需选择。属于小的可读性优化。

Also applies to: 25-27, 106-106

src/app/leaderboard.tsx (1)

60-60: 变量命名与实际类型不符。

haveTrack 命名暗示这是一个布尔值,但 useCurrentTrack() 返回的是 Track | null。虽然在 JavaScript 中作为布尔条件判断时可以正常工作,但建议重命名以提高代码可读性。

-	const haveTrack = useCurrentTrack()
+	const currentTrack = useCurrentTrack()

对应 Line 125 也需要同步修改:

-					paddingBottom: haveTrack ? 70 + insets.bottom : insets.bottom,
+					paddingBottom: currentTrack ? 70 + insets.bottom : insets.bottom,
src/features/playlist/local/components/LocalPlaylistItem.tsx (1)

50-50: 潜在的性能问题:每个列表项都会触发独立的异步调用和事件订阅

useIsCurrentTrack 钩子会为每个列表项创建独立的 Orpheus.getCurrentTrack() 调用和 onTrackTransition 事件监听器。当列表包含大量曲目时,这会导致:

  1. 初始渲染时触发 N 次异步调用
  2. 每次曲目切换时触发 N 次事件回调

建议考虑在父组件层级获取当前曲目 ID,然后通过 props 或 context 向下传递,以减少重复的订阅和调用。

src/hooks/player/useIsCurrentTrack.ts (2)

17-51: 重复逻辑可提取为辅助函数

初始获取(Lines 20-36)和事件监听回调(Lines 38-51)中的曲目比较逻辑完全相同,违反 DRY 原则。

 	useEffect(() => {
 		let isMounted = true
+
+		const updateCurrentState = (currentTrack: { id: string } | null) => {
+			if (!isMounted) return
+			setIsCurrent(currentTrack?.id === trackUniqueKey)
+		}
 
 		fetchTrack()
-			.then(({ currentTrack }) => {
-				if (isMounted) {
-					if (!currentTrack) {
-						setIsCurrent(false)
-						return
-					}
-					if (currentTrack.id === trackUniqueKey) {
-						setIsCurrent(true)
-					} else {
-						setIsCurrent(false)
-					}
-				}
-			})
+			.then(({ currentTrack }) => updateCurrentState(currentTrack))
 			.catch(() => {
 				// ignore
 			})
 
 		const sub = Orpheus.addListener('onTrackTransition', async () => {
 			const { currentTrack } = await fetchTrack()
-			if (isMounted) {
-				if (!currentTrack) {
-					setIsCurrent(false)
-					return
-				}
-				if (currentTrack.id === trackUniqueKey) {
-					setIsCurrent(true)
-				} else {
-					setIsCurrent(false)
-				}
-			}
+			updateCurrentState(currentTrack)
 		})

7-15: fetchTrack 函数可移至组件外部

fetchTrack 不依赖任何组件状态或 props,可以移至模块顶层以避免每次渲染时重新创建。

+const fetchTrack = async () => {
+	try {
+		const currentTrack = await Orpheus.getCurrentTrack()
+		return { currentTrack }
+	} catch (e) {
+		console.warn('Failed to fetch current track', e)
+		return { currentTrack: null }
+	}
+}
+
 export function useIsCurrentTrack(trackUniqueKey: string) {
 	const [isCurrent, setIsCurrent] = useState(false)
-
-	const fetchTrack = async () => {
-		try {
-			const currentTrack = await Orpheus.getCurrentTrack()
-			return { currentTrack }
-		} catch (e) {
-			console.warn('Failed to fetch current track', e)
-			return { currentTrack: null }
-		}
-	}
src/hooks/player/useCurrentTrack.ts (2)

10-18: useIsCurrentTrack 存在代码重复

fetchTrack 函数在 useIsCurrentTrack.tsuseCurrentTrack.ts 中完全相同。建议提取为共享工具函数,或让 useIsCurrentTrack 基于 useCurrentTrack 实现。

可考虑在共享位置创建工具函数:

// 例如在 src/hooks/player/utils.ts
export async function fetchCurrentOrpheusTrack() {
  try {
    const currentTrack = await Orpheus.getCurrentTrack()
    return { currentTrack }
  } catch (e) {
    console.warn('Failed to fetch current track', e)
    return { currentTrack: null }
  }
}

或者让 useIsCurrentTrack 内部使用 useCurrentTrack

export function useIsCurrentTrack(trackUniqueKey: string) {
  const currentTrack = useCurrentTrack()
  return currentTrack?.uniqueKey === trackUniqueKey
}

23-70: 初始获取和事件回调中的曲目解析逻辑重复

Lines 23-47 和 Lines 49-70 的逻辑几乎相同,可提取为辅助函数以减少重复。

 	useEffect(() => {
 		let isMounted = true
 
+		const resolveAndSetTrack = async (currentTrack: { id: string } | null) => {
+			if (!isMounted) return
+			if (!currentTrack) {
+				setTrack(null)
+				return
+			}
+			const internalTrack = await trackService.getTrackByUniqueKey(currentTrack.id)
+			if (!isMounted) return
+			if (internalTrack.isErr()) {
+				setTrack(null)
+				toastAndLogError('读取当前曲目信息失败', internalTrack.error, 'Hooks.useCurrentTrack')
+				return
+			}
+			setTrack(internalTrack.value)
+		}
+
 		fetchTrack()
-			.then(async ({ currentTrack }) => {
-				if (isMounted) {
-					// ... 23 lines of duplicate code
-				}
-			})
+			.then(({ currentTrack }) => resolveAndSetTrack(currentTrack))
 			.catch(() => {})
 
 		const sub = Orpheus.addListener('onTrackTransition', async () => {
 			const { currentTrack } = await fetchTrack()
-			if (isMounted) {
-				// ... same 17 lines
-			}
+			await resolveAndSetTrack(currentTrack)
 		})
src/features/player/hooks/usePlayerSlider.ts (2)

37-44: 缺少错误处理

Promise.all([Orpheus.getPosition(), Orpheus.getDuration()]) 失败时会静默失败。如果 Orpheus 尚未初始化或没有当前曲目,这些调用可能会抛出异常。

 	useEffect(() => {
-		void Promise.all([Orpheus.getPosition(), Orpheus.getDuration()]).then(
-			([position, duration]) => {
-				sharedPosition.set(position)
-				sharedDuration.set(duration)
-			},
-		)
+		Promise.all([Orpheus.getPosition(), Orpheus.getDuration()])
+			.then(([position, duration]) => {
+				sharedPosition.set(position)
+				sharedDuration.set(duration)
+			})
+			.catch((e) => {
+				console.warn('Failed to get initial progress', e)
+			})
 	}, [sharedDuration, sharedPosition])

58-74: Orpheus.seekTo 缺少错误处理

seek 操作失败时用户不会收到任何反馈,且 overridePosition 仍会被设置,可能导致 UI 状态与实际播放位置不一致。

 	const handleSlidingComplete = useCallback(
 		async (value: number) => {
 			void Haptics.performAndroidHapticsAsync(
 				Haptics.AndroidHaptics.Gesture_End,
 			)
 			overridePosition.set(value)
-			await Orpheus.seekTo(value)
+			try {
+				await Orpheus.seekTo(value)
+			} catch (e) {
+				console.warn('Seek failed', e)
+			}
 
 			sharedPosition.set(value)
src/features/player/components/PlayerControls.tsx (1)

96-108: 乐观更新缺少错误恢复机制

setRepeatMode(nextMode)Orpheus.setRepeatMode(nextMode) 之后立即调用,但如果 Orpheus 操作失败,本地状态不会回滚,导致 UI 与实际状态不一致。

 						onPress={() => {
 							void Haptics.performAndroidHapticsAsync(
 								Haptics.AndroidHaptics.Confirm,
 							)
 							const nextMode =
 								repeatMode === RepeatMode.OFF
 									? RepeatMode.TRACK
 									: repeatMode === RepeatMode.TRACK
 										? RepeatMode.QUEUE
 										: RepeatMode.OFF
-							void Orpheus.setRepeatMode(nextMode)
-							setRepeatMode(nextMode)
+							Orpheus.setRepeatMode(nextMode)
+								.then(() => setRepeatMode(nextMode))
+								.catch((e) => console.warn('Failed to set repeat mode', e))
 						}}
src/utils/player.ts (2)

29-33: 建议清理注释掉的调试代码。

多处调试日志被注释而非删除(Lines 29-33、44-51、76-80)。如果不再需要,建议移除以保持代码整洁。


363-378: 建议为 addToQueue 添加返回值以反馈操作结果。

函数在多个分支静默返回(Lines 377、385、412),调用方无法得知操作是否成功。考虑返回布尔值或 Result 类型以便调用方处理失败情况。

 async function addToQueue({
   tracks,
   playNow,
   clearQueue,
   startFromKey,
   playNext,
 }: {
   tracks: Track[]
   playNow: boolean
   clearQueue: boolean
   startFromKey?: string
   playNext: boolean
-}) {
+}): Promise<boolean> {
   if (!tracks || tracks.length === 0) {
-    return
+    return false
   }
   // ... 其他分支类似处理
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e65db4d and 0236ba8.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (44)
  • index.js (0 hunks)
  • package.json (2 hunks)
  • src/app/(tabs)/settings.tsx (2 hunks)
  • src/app/_layout.tsx (2 hunks)
  • src/app/download.tsx (2 hunks)
  • src/app/leaderboard.tsx (2 hunks)
  • src/app/player.tsx (2 hunks)
  • src/app/playlist/remote/toview.tsx (2 hunks)
  • src/app/test.tsx (3 hunks)
  • src/components/NowPlayingBar.tsx (6 hunks)
  • src/components/modals/PlayerQueueModal.tsx (4 hunks)
  • src/components/modals/player/SleepTimerModal.tsx (2 hunks)
  • src/features/leaderboard/LeaderBoardItem.tsx (3 hunks)
  • src/features/library/collection/CollectionList.tsx (2 hunks)
  • src/features/library/favorite/FavoriteFolderList.tsx (2 hunks)
  • src/features/library/local/LocalPlaylistList.tsx (2 hunks)
  • src/features/library/multipage/MultiPageVideosList.tsx (2 hunks)
  • src/features/player/components/PlayerControls.tsx (5 hunks)
  • src/features/player/components/PlayerFunctionalMenu.tsx (1 hunks)
  • src/features/player/components/PlayerLyrics.tsx (1 hunks)
  • src/features/player/hooks/useLyricSync.ts (4 hunks)
  • src/features/player/hooks/usePlayerSlider.ts (3 hunks)
  • src/features/playlist/local/components/LocalPlaylistHeader.tsx (1 hunks)
  • src/features/playlist/local/components/LocalPlaylistItem.tsx (2 hunks)
  • src/features/playlist/local/components/LocalTrackList.tsx (2 hunks)
  • src/features/playlist/local/hooks/useLocalPlaylistMenu.ts (2 hunks)
  • src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts (2 hunks)
  • src/features/playlist/remote/components/PlaylistItem.tsx (2 hunks)
  • src/features/playlist/remote/components/RemoteTrackList.tsx (2 hunks)
  • src/features/playlist/remote/hooks/useRemotePlaylist.ts (1 hunks)
  • src/features/playlist/remote/search-result/hooks/useSearchInteractions.ts (2 hunks)
  • src/features/playlist/remote/toview/components/Item.tsx (2 hunks)
  • src/hooks/player/useAnimatedTrackProgress.ts (2 hunks)
  • src/hooks/player/useCurrentQueue.ts (0 hunks)
  • src/hooks/player/useCurrentTrack.ts (1 hunks)
  • src/hooks/player/useIsCurrentTrack.ts (1 hunks)
  • src/hooks/player/useTrackProgress.ts (3 hunks)
  • src/hooks/stores/usePlayerStore.ts (0 hunks)
  • src/lib/player/playbackService.ts (0 hunks)
  • src/lib/player/playerLogic.ts (0 hunks)
  • src/lib/player/progressListener.ts (2 hunks)
  • src/lib/services/downloadService.ts (0 hunks)
  • src/types/core/playerStore.ts (0 hunks)
  • src/utils/player.ts (5 hunks)
💤 Files with no reviewable changes (7)
  • src/types/core/playerStore.ts
  • src/lib/player/playerLogic.ts
  • index.js
  • src/lib/services/downloadService.ts
  • src/lib/player/playbackService.ts
  • src/hooks/stores/usePlayerStore.ts
  • src/hooks/player/useCurrentQueue.ts
🧰 Additional context used
🧬 Code graph analysis (29)
src/features/player/components/PlayerFunctionalMenu.tsx (2)
src/hooks/stores/usePlayerStore.ts (1)
  • durationInSeconds (66-76)
src/components/modals/app/UpdateAppModal.tsx (1)
  • UpdateAppModal (18-90)
src/features/playlist/remote/components/PlaylistItem.tsx (2)
src/hooks/player/useIsCurrentTrack.ts (1)
  • useIsCurrentTrack (4-60)
src/hooks/stores/usePlayerStore.ts (7)
  • state (401-436)
  • currentTrackUniqueKey (624-650)
  • Track (83-88)
  • track (403-405)
  • index (719-824)
  • track (689-717)
  • state (254-262)
src/app/playlist/remote/toview.tsx (1)
src/hooks/stores/usePlayerStore.ts (2)
  • addToQueueParams (320-451)
  • currentTrackUniqueKey (624-650)
src/features/library/collection/CollectionList.tsx (2)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/hooks/stores/usePlayerStore.ts (6)
  • state (401-436)
  • track (403-405)
  • track (689-717)
  • currentTrackUniqueKey (624-650)
  • track (354-354)
  • Track (83-88)
src/features/playlist/remote/search-result/hooks/useSearchInteractions.ts (1)
src/hooks/stores/usePlayerStore.ts (9)
  • state (401-436)
  • addToQueueParams (320-451)
  • currentTrackUniqueKey (624-650)
  • Sentry (454-530)
  • Sentry (567-583)
  • Sentry (532-565)
  • track (403-405)
  • state (254-262)
  • state (283-295)
src/features/leaderboard/LeaderBoardItem.tsx (2)
src/hooks/player/useIsCurrentTrack.ts (1)
  • useIsCurrentTrack (4-60)
src/hooks/stores/usePlayerStore.ts (1)
  • currentTrackUniqueKey (624-650)
src/features/library/multipage/MultiPageVideosList.tsx (1)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/features/player/hooks/usePlayerSlider.ts (1)
src/hooks/stores/usePlayerStore.ts (5)
  • span (702-715)
  • reason (96-224)
  • Sentry (454-530)
  • track (689-717)
  • startAtPosition (841-927)
src/app/leaderboard.tsx (2)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/hooks/stores/usePlayerStore.ts (6)
  • track (689-717)
  • track (403-405)
  • track (354-354)
  • Track (83-88)
  • currentTrackUniqueKey (624-650)
  • currentTrack (226-230)
src/features/playlist/remote/components/RemoteTrackList.tsx (1)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/features/library/local/LocalPlaylistList.tsx (1)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/features/playlist/local/components/LocalPlaylistItem.tsx (1)
src/hooks/player/useIsCurrentTrack.ts (1)
  • useIsCurrentTrack (4-60)
src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts (2)
src/lib/db/schema.ts (1)
  • tracks (51-84)
src/hooks/stores/usePlayerStore.ts (6)
  • addToQueueParams (320-451)
  • state (401-436)
  • Sentry (532-565)
  • Sentry (454-530)
  • currentTrackUniqueKey (624-650)
  • Sentry (567-583)
src/hooks/player/useIsCurrentTrack.ts (1)
src/hooks/stores/usePlayerStore.ts (9)
  • track (354-354)
  • track (689-717)
  • currentTrackUniqueKey (624-650)
  • track (403-405)
  • Track (83-88)
  • state (401-436)
  • track (400-400)
  • uniqueKey (232-307)
  • currentTrack (226-230)
src/features/library/favorite/FavoriteFolderList.tsx (2)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/hooks/stores/usePlayerStore.ts (7)
  • state (401-436)
  • track (689-717)
  • currentTrackUniqueKey (624-650)
  • track (403-405)
  • track (354-354)
  • state (830-834)
  • Track (83-88)
src/app/(tabs)/settings.tsx (2)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/hooks/stores/usePlayerStore.ts (7)
  • state (401-436)
  • track (403-405)
  • track (689-717)
  • Track (83-88)
  • track (354-354)
  • currentTrackUniqueKey (624-650)
  • state (830-834)
src/features/player/components/PlayerControls.tsx (2)
src/types/core/appStore.ts (1)
  • AppState (31-31)
src/hooks/stores/usePlayerStore.ts (5)
  • Sentry (532-565)
  • state (401-436)
  • Sentry (454-530)
  • index (719-824)
  • currentTrackUniqueKey (624-650)
src/components/NowPlayingBar.tsx (1)
src/hooks/stores/usePlayerStore.ts (3)
  • Sentry (454-530)
  • activeList (538-563)
  • Sentry (567-583)
src/features/playlist/remote/toview/components/Item.tsx (2)
src/hooks/player/useIsCurrentTrack.ts (1)
  • useIsCurrentTrack (4-60)
src/hooks/stores/usePlayerStore.ts (6)
  • state (401-436)
  • track (689-717)
  • track (403-405)
  • currentTrackUniqueKey (624-650)
  • track (354-354)
  • Track (83-88)
src/hooks/player/useAnimatedTrackProgress.ts (1)
src/hooks/stores/usePlayerStore.ts (3)
  • span (702-715)
  • track (689-717)
  • reason (96-224)
src/features/player/hooks/useLyricSync.ts (1)
src/hooks/stores/usePlayerStore.ts (2)
  • position (585-602)
  • Sentry (567-583)
src/app/download.tsx (2)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/hooks/stores/usePlayerStore.ts (6)
  • state (401-436)
  • track (689-717)
  • track (403-405)
  • currentTrackUniqueKey (624-650)
  • Track (83-88)
  • currentTrack (226-230)
src/app/test.tsx (2)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/hooks/stores/usePlayerStore.ts (8)
  • state (401-436)
  • currentTrackUniqueKey (624-650)
  • track (689-717)
  • Sentry (454-530)
  • logger (238-305)
  • index (719-824)
  • checkPlayerReady (339-449)
  • track (403-405)
src/features/player/components/PlayerLyrics.tsx (1)
src/features/player/hooks/useLyricSync.ts (1)
  • useLyricSync (9-145)
src/hooks/player/useCurrentTrack.ts (4)
src/types/core/media.ts (1)
  • Track (72-72)
src/lib/services/trackService.ts (1)
  • trackService (885-885)
src/utils/error-handling.ts (1)
  • toastAndLogError (11-41)
src/hooks/stores/usePlayerStore.ts (5)
  • track (689-717)
  • Track (83-88)
  • currentTrack (226-230)
  • track (403-405)
  • currentTrackUniqueKey (624-650)
src/features/playlist/local/components/LocalTrackList.tsx (2)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/hooks/stores/usePlayerStore.ts (8)
  • state (401-436)
  • currentTrackUniqueKey (624-650)
  • track (403-405)
  • track (689-717)
  • currentTrack (226-230)
  • span (702-715)
  • index (719-824)
  • Track (83-88)
src/components/modals/PlayerQueueModal.tsx (2)
src/hooks/router/usePreventRemove.ts (1)
  • usePreventRemove (5-37)
src/hooks/stores/usePlayerStore.ts (9)
  • state (401-436)
  • currentTrackUniqueKey (624-650)
  • addToQueueParams (320-451)
  • index (719-824)
  • activeList (725-822)
  • activeList (538-563)
  • Sentry (532-565)
  • checkPlayerReady (339-449)
  • logger (238-305)
src/app/player.tsx (1)
src/hooks/stores/usePlayerStore.ts (6)
  • track (689-717)
  • span (702-715)
  • currentTrack (226-230)
  • index (719-824)
  • track (403-405)
  • Track (83-88)
src/app/_layout.tsx (1)
src/hooks/stores/useAppStore.ts (2)
  • useAppStore (57-249)
  • serializeCookieObject (39-45)
🪛 Biome (2.1.2)
src/hooks/player/useTrackProgress.ts

[error] 70-70: Comparing to itself is potentially pointless.

(lint/suspicious/noSelfCompare)


[error] 97-97: Comparing to itself is potentially pointless.

(lint/suspicious/noSelfCompare)

🔇 Additional comments (31)
package.json (3)

4-5: 版本号提升合理,符合重大框架迁移。

从 1.4.3 升至 2.0.0(versionCode 81 → 82)合理地反映了从 TrackPlayer 到 Orpheus 的重大播放器框架迁移。


116-116: 验证 zustand 是否在迁移后仍被使用。

据 AI 摘要,本次迁移移除了本地 Zustand 播放器存储逻辑,改用 Orpheus API。请确认 zustand 是否在代码库中的其他地方使用,或是否可作为孤立依赖移除。


37-37: @roitium/expo-orpheus@0.2.2 is compatible with your project dependencies.

The package exists on npm (latest: 0.2.2, published Dec 5, 2025) and declares wildcard peer dependencies for expo, react, and react-native, making it compatible with Expo ~54.0.21 and the current dependency tree. As a pre-1.0 package, the caret range ^0.2.2 could allow breaking changes in 0.3.0, but the package has no transitive dependencies, so version changes won't cause cascading issues. Since the author (Roitium) maintains both this package and BBPlayer, version alignment is controlled. The recent release date (34 minutes ago at time of check) may warrant testing to ensure stability in your environment.

src/features/playlist/remote/search-result/hooks/useSearchInteractions.ts (3)

25-31: 确认参数兼容性。

addToQueue 的调用参数结构与旧实现保持一致,这很好。请验证新的工具函数是否接受相同的参数对象结构,特别是:

  • tracks 数组中的 BilibiliTrack 类型
  • playNowplayNext 的互斥逻辑
  • startFromKey 的行为(在 Orpheus 框架下是否正常工作)

33-33: 依赖数组更新正确。

从依赖数组中移除 addToQueue 是正确的做法。由于 addToQueue 现在是从模块直接导入的工具函数(而不是从 store 派生),它是一个稳定的引用,不需要包含在 useCallback 的依赖数组中。保留 router 依赖也是正确的。


4-4: The code is correctly implemented and requires no changes.

The import of addToQueue from @/utils/player is appropriate. addToQueue is a stable utility function that doesn't depend on component props, state, or other hooks. Therefore, it should not be included in the dependency array at line 33—the current dependency array [router] is correct. The pattern is consistent across the codebase in all files that consume addToQueue.

Likely an incorrect or invalid review comment.

src/features/player/hooks/useLyricSync.ts (2)

67-82: LGTM!迁移到 Orpheus.seekTo 正确。

handleJumpToLyric 的逻辑保持不变,依赖数组正确地移除了 seekTo(现在使用模块级别的 Orpheus API)。


108-118: LGTM!数据获取逻辑迁移正确。

TrackPlayer.getProgress() 返回对象改为 Orpheus.getPosition() 返回数字,数据处理逻辑相应调整正确。

src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts (1)

5-5: LGTM!正确迁移到模块级别的 addToQueue

usePlayerStore 迁移到 @/utils/player 的模块导入,依赖数组正确移除了 addToQueue(模块级函数引用稳定)。

Also applies to: 34-34

src/hooks/player/useAnimatedTrackProgress.ts (1)

39-49: LGTM!并行获取进度数据效率良好。

使用 Promise.all 并行调用三个 Orpheus API 是正确的优化方式,shared value 的 .set() 方法使用正确。

src/components/modals/PlayerQueueModal.tsx (1)

133-149: LGTM!处理器迁移到索引方式正确。

switchTrackHandlerremoveTrackHandler 正确使用索引调用 Orpheus API,并在操作后触发 refetch() 更新 UI 状态。

src/app/test.tsx (2)

33-33: LGTM - 使用 Hook 检测曲目存在性。

haveTrack 的来源从 store selector 改为 useCurrentTrack() hook,与 PR 中其他组件的迁移模式一致。由于 haveTrack 在第 156 行用于真值检查(haveTrack ? 80 : 20),Track | null 类型可以正确工作。


202-202: Orpheus.clear() clears only the playback queue; verify it replaces all original resetStore() cleanup.

The UI button explicitly labels this as "清空播放器队列" (Clear player queue), and the code shows Orpheus.clear() is called conditionally when clearQueue=true. This indicates the method clears only the queue. However, if the original usePlayerStore.resetStore() also cleared repeat mode, shuffle mode, or other player state, those will not be reset by Orpheus.clear() alone—those states are managed separately via Orpheus.setRepeatMode(), Orpheus.setShuffleMode(), etc. Confirm whether a full player state reset is still needed.

src/features/playlist/remote/components/RemoteTrackList.tsx (1)

2-2: LGTM - 迁移到集中式的 Hook。

与 PR 中其他组件保持一致,使用 useCurrentTrack() 替代 store selector 来检测当前曲目。第 185 行的 paddingBottom: haveTrack ? 70 + insets.bottom : insets.bottom 可以正确处理 Track | null 类型。

Also applies to: 120-120

src/app/download.tsx (1)

4-4: LGTM - 迁移模式一致。

与项目中其他组件的重构保持一致,使用 useCurrentTrack() hook 进行曲目存在性检查。

Also applies to: 30-30

src/features/library/collection/CollectionList.tsx (1)

4-4: LGTM - Hook 迁移正确。

采用 useCurrentTrack() 替代 store selector,与 PR 的整体迁移策略一致。

Also applies to: 21-21

src/features/player/components/PlayerLyrics.tsx (1)

305-305: LGTM - 简化 Hook 调用。

移除 seekTo 参数后,useLyricSync hook 内部直接使用 Orpheus.seekTo(见相关代码片段 src/features/player/hooks/useLyricSync.ts 第 70 行),使职责划分更清晰。这与 PR 从 TrackPlayer 迁移到 Orpheus 的整体架构一致。

src/features/library/favorite/FavoriteFolderList.tsx (1)

4-4: LGTM - 一致的迁移实现。

使用 useCurrentTrack() 进行曲目检测,与项目中其他组件的重构方式保持一致。

Also applies to: 23-23

src/features/playlist/local/components/LocalTrackList.tsx (1)

2-2: LGTM - 完成 Hook 迁移。

与 PR 中其他组件保持一致,使用 useCurrentTrack() 替代 store-based 检查。

Also applies to: 94-94

src/features/player/components/PlayerFunctionalMenu.tsx (1)

123-124: Verify the status and scope of sleep timer feature removal.

The sleep timer feature is completely disabled with placeholder implementations: PlayerFunctionalMenu shows a "not implemented" toast instead of opening the modal, and SleepTimerModal contains only commented-out code with an error message. All references to sleepTimerEndAt exist only within comments, not in active code.

Clarify whether this is a planned re-implementation for a future PR or if the entire feature (component, modal registration, and navigation type) should be removed.

src/app/player.tsx (1)

13-13: 切换到 @roitium/expo-orpheususeCurrentTrack 后请确认返回数据结构

这里从本地 useCurrentTrack 切到了 Orpheus 包里的实现,并改为:

const currentTrack = useCurrentTrack()
const coverRef = useImage(currentTrack?.track?.artwork ?? '', {
  onError: () => void 0,
})

整体思路没问题,但有两个点建议确认:

  1. @roitium/expo-orpheususeCurrentTrack 是否始终返回包含 track.artwork 字段的对象?如果返回值结构不同(例如直接是 track 而不是带 track 嵌套),这里会导致封面始终为空或抛异常。
  2. 如果第三方库内部在没有当前曲目时返回 undefined 或其它占位值,确保与本地逻辑(无曲目时回退为纯背景色)行为一致。

建议在真机/模拟器上实际切换几首歌,确认背景渐变和封面联动正常。

Also applies to: 55-58

src/features/playlist/local/hooks/useLocalPlaylistMenu.ts (1)

9-11: playNext 迁移到 Orpheus 队列 API 的实现整体合理,注意确认转换函数签名

新的实现:

const playNext = useCallback(async (track: Track) => {
  try {
    const oTrack = convertToOrpheusTrack(track)
    if (oTrack.isErr()) {
      toastAndLogError('转换 Track 失败', oTrack.error, SCOPE)
      return
    }
    await Orpheus.playNext(oTrack.value)
    toast.success('添加到下一首播放成功')
  } catch (error) {
    toastAndLogError('添加到队列失败', error, SCOPE)
  }
}, [])

优点:

  • 对转换失败和 playNext 异常都有统一的 toastAndLogError 处理;
  • 将语义从“本地 store 入队”平滑迁移到 Orpheus 队列,接口更直接。

唯一需要确认的是 convertToOrpheusTrack 的签名:

  • 如果它是同步函数并返回 Result<OrpheusTrack, Error>,当前用法是正确的;
  • 如果未来演进为 Promise<Result<...>>,这里需要改成 const oTrack = await convertToOrpheusTrack(track)

建议在本地快速测试“下一首播放”是否符合预期(含错误路径)。

Also applies to: 36-48

src/features/playlist/remote/hooks/useRemotePlaylist.ts (1)

6-14: 代码逻辑正确。

playTrack 回调正确处理了 playNext 参数逻辑:当 playNext=true 时,playNow=falseplayNext=true,将曲目插入到当前播放之后;反之则立即播放。依赖数组为空是正确的,因为 addToQueue 是稳定的模块级导入。

src/components/NowPlayingBar.tsx (3)

167-167: 良好的空值处理。

currentTrack.title 添加 '未知曲目' 作为空值回退是合理的防御性编程实践。


87-87: LGTM!

Orpheus.skipToPrevious()Orpheus.skipToNext() 的调用符合迁移到 Orpheus 的整体模式。

Also applies to: 111-111


96-102: The concern about a stale closure with isPlaying is unfounded. The gesture handlers (prevTap, playTap, nextTap) are defined directly in the component body without memoization (lines 81-112), meaning they are recreated on each render and capture the current isPlaying value from that render cycle. When a gesture fires and calls scheduleOnRN(callback, isPlaying), the isPlaying value passed is from the current render cycle—not a stale captured value. This pattern is correct for react-native-reanimated architecture and does not present a closure staleness issue.

Likely an incorrect or invalid review comment.

src/app/playlist/remote/toview.tsx (1)

92-104: 代码迁移正确。

handlePlayAll 正确地从模块导入的 addToQueue 工具函数替代了之前的 store 方法。依赖数组 [tracksData] 是正确的,因为 addToQueue 是稳定的模块级导入,不需要包含在依赖中。

src/features/leaderboard/LeaderBoardItem.tsx (2)

35-44: 迁移正确,startFromKey 参数略显冗余。

使用 useIsCurrentTrack hook 替代 store 的判断是正确的迁移。但当 tracks 数组只有一个元素且 playNow: true 时,startFromKey 参数是多余的,因为播放器会默认从第一个(也是唯一的)曲目开始播放。不过保留它也不会造成问题,可以视为显式声明意图。


24-24: LGTM!

使用 useIsCurrentTrack(item.track.uniqueKey) hook 替代之前从 store 读取 currentTrackUniqueKey 的方式,符合本次 PR 向 Orpheus 迁移的整体模式。

src/features/player/components/PlayerControls.tsx (2)

30-63: 播放控制操作的整体实现良好

迁移到 Orpheus API 的播放控制逻辑清晰,触觉反馈保留完好。void 用于标记有意忽略 Promise 返回值是合理的做法。


14-28: repeatMode state management differs from shuffleMode because useRepeatMode hook is not exported by @roitium/expo-orpheus

The library exports useIsPlaying and useShuffleMode but not useRepeatMode. The current implementation using useState + useEffect with AppState listener is the appropriate pattern given the library's available exports. No changes needed.

Comment thread src/components/modals/player/SleepTimerModal.tsx Outdated
Comment thread src/components/modals/player/SleepTimerModal.tsx Outdated
Comment thread src/components/modals/PlayerQueueModal.tsx
Comment thread src/hooks/player/useTrackProgress.ts
Comment thread src/hooks/player/useTrackProgress.ts
Comment thread src/utils/player.ts Outdated
Comment thread src/utils/player.ts Outdated
Comment thread src/utils/player.ts
@roitium roitium changed the title feat: wtf feat: native player Dec 5, 2025
@roitium roitium mentioned this pull request Dec 5, 2025
7 tasks
@roitium roitium marked this pull request as ready for review December 9, 2025 14:43
@roitium
Copy link
Copy Markdown
Collaborator Author

roitium commented Dec 9, 2025

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Dec 9, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/modals/player/SleepTimerModal.tsx (1)

26-45: 移除误导性的"暂未实现"提示

Line 27 在 useEffect 中显示 toast.error('暂未实现'),但从代码来看睡眠定时器功能已完整实现(包括设置、取消、剩余时间显示等)。此错误提示会:

  1. 误导用户认为功能不可用,实际上功能正常工作
  2. 每次 sleepTimerEndAt 变化时都会触发(不仅是挂载时),造成重复干扰
  3. 与实际可用的完整 UI 和功能逻辑矛盾

如果功能确实已完全迁移并可用,应直接删除 Line 27;如果存在已知限制或部分功能未完成,请改用具体的提示文案(如"部分功能开发中")并使用 toast.info 级别。

 useEffect(() => {
-  toast.error('暂未实现')
   if (sleepTimerEndAt) {
♻️ Duplicate comments (4)
src/components/modals/player/SleepTimerModal.tsx (1)

53-57: 此段的问题已在前一条评论中标注

此函数与 handleSetTimer 存在相同的错误处理缺失问题,已在 Lines 47-51 的评论中一并指出。

src/utils/player.ts (2)

124-195: startFromKey 的行为依赖原生层,建议在注释中进一步明确与 playNow 的关系

JSDoc 中写明 startFromKey 会“从指定 key 开始播放(并立即开始播放,无视 playNow)”,而实现里:

  • 调用 Orpheus.addToEnd(orpheusTracks, startFromKey, clearQueue)
  • 并且只在 playNow && !startFromKey 时手动调用 Orpheus.play(),注释说明“原生层已经处理了 startFromKey 的播放逻辑”。

也就是说:当传入 startFromKey 时,是否“立即播放、无视 playNow”完全取决于 Orpheus 的实现。建议:

  • 确认 Orpheus 在带 startFromKey 时一定会自动开始播放;
  • 在 JSDoc 或代码注释中显式写明“依赖原生 Orpheus 行为,如果未来 native 行为变化,这里可能需要同时更新”。

否则,一旦原生实现或接口发生变更,这里会比较难排查行为偏差。


32-48: 响度信息被硬编码为 0,响度归一化可能失效

当前 volume 始终是 { measured_i: 0, target_i: 0 },原先从 track.bilibiliMetadata.bilibiliStreamUrl.volume 读取真实响度的逻辑被全部注释掉,这会让 Orpheus 无法进行有效的响度归一化(所有音轨“看上去”都是 0dB)。

如果 B 站返回的数据结构里仍然有响度信息,建议恢复按真实值填充 volume(或者明确选择放弃响度归一化并删除相关字段和注释):

-	let url = ''
-	const volume = {
-		measured_i: 0,
-		target_i: 0,
-	}
+	let url = ''
+	let volume = {
+		measured_i: 0,
+		target_i: 0,
+	}
@@
-	if (track.source === 'bilibili') {
+	if (track.source === 'bilibili') {
@@
-		// volume = {
-		// 	measured_i:
-		// 		track.bilibiliMetadata.bilibiliStreamUrl.volume?.measured_i ?? 0,
-		// 	target_i: track.bilibiliMetadata.bilibiliStreamUrl.volume?.target_i ?? 0,
-		// }
+		volume = {
+			measured_i:
+				track.bilibiliMetadata.bilibiliStreamUrl?.volume?.measured_i ?? 0,
+			target_i:
+				track.bilibiliMetadata.bilibiliStreamUrl?.volume?.target_i ?? 0,
+		}

如果新架构下已经不再维护 bilibiliStreamUrl.volume,也建议同步调整类型与注释,避免“看上去支持响度,但其实永远是 0”这种半残状态。

src/components/modals/PlayerQueueModal.tsx (1)

133-149: Orpheus 操作缺少错误处理

Orpheus.skipToOrpheus.removeTrack 调用可能会抛出异常,但当前实现没有 try-catch 包裹。如果这些操作失败,用户界面将不会给出任何反馈。

建议添加错误处理:

 const switchTrackHandler = useCallback(
 	async (index: number) => {
 		if (!queue) return
 		if (index === -1) return
-		await Orpheus.skipTo(index)
-		void refetch()
+		try {
+			await Orpheus.skipTo(index)
+			await refetch()
+		} catch (error) {
+			console.error('切换曲目失败', error)
+		}
 	},
 	[queue, refetch],
 )

 const removeTrackHandler = useCallback(
 	async (index: number) => {
-		await Orpheus.removeTrack(index)
-		void refetch()
+		try {
+			await Orpheus.removeTrack(index)
+			await refetch()
+		} catch (error) {
+			console.error('移除曲目失败', error)
+		}
 	},
 	[refetch],
 )

此外,这也解决了之前 review 中提到的 void refetch() 可能导致静默失败的问题。

🧹 Nitpick comments (24)
src/components/modals/player/SleepTimerModal.tsx (1)

15-22: 建议适度放宽缓存配置

当前 gcTime: 0staleTime: 0 的配置会导致查询数据完全不缓存且始终被视为过期,可能引发不必要的重复请求。考虑到睡眠定时器状态变化频率较低且此弹窗生命周期较短,可以适度放宽(例如 staleTime: 1000 或更长),以减少不必要的网络请求。

src/features/playlist/local/hooks/useLocalPlaylistMenu.ts (1)

44-47: 考虑使 downloadState 参数必需。

downloadState 参数是可选的,但在函数体内多处使用(第 86、88、92 行)。如果不传递此参数,下载相关菜单项的行为可能不正确。

建议评估是否应该将此参数设为必需:

 const menuFunctions = (
   item: Track,
-  downloadState?: DownloadState,
+  downloadState: DownloadState,
 ): TrackMenuItem[] => {

或者,在函数开头添加默认值处理,确保所有调用路径都能正确工作。

src/features/player/components/PlayerControls.tsx (1)

112-119: 考虑为循环模式切换添加错误处理。

循环模式的切换使用了乐观更新(立即调用 setRepeatMode),但对 Orpheus.setRepeatMode 的调用是 fire-and-forget 的(使用 void),如果调用失败,本地状态和实际状态可能不同步。

建议添加错误处理:

 onPress={() => {
   void Haptics.performAndroidHapticsAsync(
     Haptics.AndroidHaptics.Confirm,
   )
   const nextMode =
     repeatMode === RepeatMode.OFF
       ? RepeatMode.TRACK
       : repeatMode === RepeatMode.TRACK
         ? RepeatMode.QUEUE
         : RepeatMode.OFF
-  void Orpheus.setRepeatMode(nextMode)
-  setRepeatMode(nextMode)
+  void Orpheus.setRepeatMode(nextMode)
+    .then(() => setRepeatMode(nextMode))
+    .catch((error) => {
+      // 处理错误,可能需要回滚状态或显示错误提示
+      console.error('设置循环模式失败:', error)
+    })
 }}

或者,如果 Orpheus 足够可靠,当前的乐观更新方式也是可接受的。

src/hooks/stores/useDownloadManagerStore.ts (1)

2-24: 下载进度事件映射整体合理,仅有命名与语义上的小建议

  • 事件转发逻辑清晰,字段映射也和 Orpheus 事件一一对应,看起来可以直接被 UI/Hooks 消费。
  • eventListner 拼写有点怪(少了一个 e),如果后续有时间可以顺手改成 eventListener 以免困惑。
  • percent 直接使用 event.percentDownloaded,建议确认下该值是 0–1 还是 0–100;如果对外有约定(例如统一用 0–1),可以在 ProgressEvent 的注释里明确一下,避免误用。
src/utils/player.ts (1)

213-261: 完成播放记录的计算逻辑清晰,可以考虑补充边界条件说明

这段 finalizeAndRecordCurrentTrack 对播放完成度的计算比较严谨:

  • position / realDuration 做了下限和上限裁剪;
  • 使用 max(90%, duration - 2s) 作为完成阈值,兼顾短曲目和长曲目;
  • 仅在写入播放记录成功后才刷新排行榜查询并上报播放历史。

建议的微调点:

  • 可以在函数注释里简单说明“完成阈值”的策略(90% 或少于 2 秒未播完),方便以后维护者理解为什么是这个数字;
  • trackService.addPlayRecordFromUniqueKey 如果未来可能返回 Ok(null) 之类“软失败”,当前只用 isErr() 判断就看不到这种情况,后续如果接口有变更记得同步调整。

整体实现本身是靠谱的。

src/hooks/player/useBatchDownloadStatus.ts (1)

1-13: ids 为空时建议禁用查询,避免不必要的调用

Hook 整体很简洁,不过当前即便 ids 为空数组,也会发起一次 Orpheus.getDownloadStatusByIds([]) 调用,可能既没意义还存在 API 约束风险。

可以加一个 enabled 条件,让空列表时直接跳过查询:

 export function useBatchDownloadStatus(ids: string[]) {
 	return useQuery({
 		queryKey: ['batchDownloadStatus', ids],
 		queryFn: async () => {
 			return await Orpheus.getDownloadStatusByIds(ids)
 		},
 		staleTime: 0,
 		gcTime: 0,
+		enabled: ids.length > 0,
 	})
 }
src/app/test.tsx (1)

4-195: 测试页使用 Orpheus 的逻辑基本可靠,可补充错误处理与文案微调

  • haveTrack = useCurrentTrack() 直接用于三元表达式作为布尔值是 OK 的(有当前曲目时为对象、无曲目时为 null)。
  • 清理下载缓存这块已经改为调用 Orpheus.removeAllDownloads(),并有 try/catch 和 toast,行为清晰;不过提示文案里仍然提到“数据库记录”,在当前 schema(已删 track_downloads)下可能略有误导,可以考虑改成“下载记录及实际文件”等更泛化的描述。
  • “清空播放器队列”按钮的 onPress={() => Orpheus.clear()} 没有处理异步错误,建议与前面下载清理保持一致,显式等待并捕获异常,例如:
-					onPress={() => Orpheus.clear()}
+					onPress={async () => {
+						try {
+							await Orpheus.clear()
+							logger.info('清空播放器队列成功')
+						} catch (error) {
+							toastAndLogError('清空播放器队列失败', error, 'TestPage')
+						}
+					}}
src/features/playlist/remote/hooks/useRemotePlaylist.ts (1)

25-31: addToQueue 的错误未被处理

使用 void 忽略了 addToQueue 的 Promise,如果入队失败,用户将不会收到任何反馈。考虑添加错误处理或使用 await

-		void addToQueue({
+		await addToQueue({
 			tracks: [track],
 			playNow: !playNext,
 			clearQueue: false,
 			playNext: playNext,
 			startFromKey: track.uniqueKey,
-		})
+		}).catch((e) => {
+			toastAndLogError('加入队列失败', e, 'UI.Playlist.Remote')
+		})
src/hooks/player/useIsCurrentTrack.ts (2)

7-15: fetchTrack 移入 useEffect 或使用 useCallback

fetchTrack 在组件顶层定义,每次渲染都会重新创建。虽然当前不会导致 bug(因为 effect 只在 trackUniqueKey 变化时重新执行),但将其移入 useEffect 内部更清晰,避免潜在的闭包问题。


21-51: 重复的状态更新逻辑可简化

初始化获取和事件监听器中的比较逻辑完全相同,可以提取为辅助函数,并简化比较表达式。

 useEffect(() => {
   let isMounted = true

+  const updateCurrentState = (currentTrack: { id: string } | null) => {
+    if (isMounted) {
+      setIsCurrent(currentTrack?.id === trackUniqueKey)
+    }
+  }
+
+  const fetchAndUpdate = async () => {
+    const { currentTrack } = await fetchTrack()
+    updateCurrentState(currentTrack)
+  }

-  fetchTrack()
-    .then(({ currentTrack }) => {
-      if (isMounted) {
-        if (!currentTrack) {
-          setIsCurrent(false)
-          return
-        }
-        if (currentTrack.id === trackUniqueKey) {
-          setIsCurrent(true)
-        } else {
-          setIsCurrent(false)
-        }
-      }
-    })
-    .catch(() => {
-      // ignore
-    })
+  fetchAndUpdate().catch(() => {})

-  const sub = Orpheus.addListener('onTrackStarted', async () => {
-    const { currentTrack } = await fetchTrack()
-    if (isMounted) {
-      if (!currentTrack) {
-        setIsCurrent(false)
-        return
-      }
-      if (currentTrack.id === trackUniqueKey) {
-        setIsCurrent(true)
-      } else {
-        setIsCurrent(false)
-      }
-    }
-  })
+  const sub = Orpheus.addListener('onTrackStarted', () => {
+    fetchAndUpdate().catch(() => {})
+  })

   return () => {
     isMounted = false
     sub.remove()
   }
 }, [trackUniqueKey])
src/app/(tabs)/settings.tsx (1)

256-261: 设置切换未等待 Orpheus 调用完成

Orpheus.setRestorePlaybackPositionEnabled 的调用没有 await,如果用户快速切换,可能导致状态不一致。建议处理异步调用:

 onValueChange={() => {
-  Orpheus.setRestorePlaybackPositionEnabled(
+  void Orpheus.setRestorePlaybackPositionEnabled(
     !enablePersistCurrentPosition,
-  )
+  ).then(() => {
+    setEnablePersistCurrentPosition(!enablePersistCurrentPosition)
+  })
-  setEnablePersistCurrentPosition(!enablePersistCurrentPosition)
 }}

或者如果 setRestorePlaybackPositionEnabled 是同步的,请忽略此建议。

src/hooks/player/useCurrentTrack.ts (2)

10-18: useIsCurrentTrack 相同的建议:将 fetchTrack 移入 useEffect

fetchTrack 函数在组件顶层定义,建议移入 useEffect 内部以保持一致性和清晰度。


23-47: 重复的 track 解析逻辑可提取为辅助函数

初始化和事件处理中的 track 获取、解析、错误处理逻辑完全相同。提取辅助函数可减少代码重复:

 useEffect(() => {
   let isMounted = true

+  const resolveAndSetTrack = async () => {
+    const { currentTrack } = await fetchTrack()
+    if (!isMounted) return
+    
+    if (!currentTrack) {
+      setTrack(null)
+      return
+    }
+    
+    const internalTrack = await trackService.getTrackByUniqueKey(currentTrack.id)
+    if (!isMounted) return
+    
+    if (internalTrack.isErr()) {
+      setTrack(null)
+      toastAndLogError('读取当前曲目信息失败', internalTrack.error, 'Hooks.useCurrentTrack')
+      return
+    }
+    setTrack(internalTrack.value)
+  }

-  fetchTrack()
-    .then(async ({ currentTrack }) => {
-      // ... 重复逻辑
-    })
-    .catch(() => {})
+  resolveAndSetTrack().catch(() => {})

-  const sub = Orpheus.addListener('onTrackStarted', async () => {
-    // ... 重复逻辑
-  })
+  const sub = Orpheus.addListener('onTrackStarted', () => {
+    resolveAndSetTrack().catch(() => {})
+  })

   return () => {
     isMounted = false
     sub.remove()
   }
 }, [])

Also applies to: 49-70

src/features/player/components/PlayerFunctionalMenu.tsx (1)

27-29: 空字符串 key 可能导致无意义的查询

currentTracknull 时,会传递空字符串 ''useBatchDownloadStatus,这可能导致向 Orpheus 发起无意义的查询请求。

建议在 currentTrack 存在时才启用查询:

 const { data: downloadStatus } = useBatchDownloadStatus([
 	currentTrack?.uniqueKey ?? '',
-])
+], { enabled: !!currentTrack })

或者在 hook 内部处理空数组/空字符串的情况。

src/hooks/stores/useAppStore.ts (1)

242-247: 日志信息在无 cookie 时可能产生误导

bilibiliCookienull 时,日志仍会输出"应用启动时自动设置 cookie 到 orpheus",但实际上并未执行任何设置操作。

 useAppStore.persist.onFinishHydration((state) => {
 	if (state.bilibiliCookie) {
 		Orpheus.setBilibiliCookie(serializeCookieObject(state.bilibiliCookie))
+		logger.info('应用启动时自动设置 cookie 到 orpheus')
 	}
-	logger.info('应用启动时自动设置 cookie 到 orpheus')
 })
src/app/playlist/remote/toview.tsx (1)

110-116: addToQueue 错误未被处理

addToQueue 使用 void 前缀忽略了 Promise 结果。如果队列添加失败,用户不会收到任何反馈。

考虑添加错误处理或至少记录日志:

-	void addToQueue({
-		tracks: [track],
-		playNow: true,
-		clearQueue: false,
-		playNext: false,
-	})
+	addToQueue({
+		tracks: [track],
+		playNow: true,
+		clearQueue: false,
+		playNext: false,
+	}).catch((e) => {
+		toastAndLogError('添加到播放队列失败', e, 'UI.Playlist.Remote')
+	})
src/features/playlist/local/components/LocalPlaylistHeader.tsx (1)

98-118: 批量下载缺少错误处理

Orpheus.multiDownload 使用 void 忽略了 Promise,如果批量下载失败,用户不会收到任何错误提示。

-	void Orpheus.multiDownload(
+	Orpheus.multiDownload(
 		tracksResult.value
 			.filter((item) =>
 				item.source === 'bilibili'
 					? item.bilibiliMetadata.videoIsValid
 					: true,
 			)
 			.map((t) => {
 				const url = getInternalPlayUri(t)
 				if (!url) return
 				return {
 					id: t.uniqueKey,
 					title: t.title,
 					url: url,
 					artist: t.artist?.name,
 					artwork: t.coverUrl ?? undefined,
 					duration: t.duration,
 				}
 			})
 			.filter((t) => !!t),
-	)
+	).catch((e) => {
+		toastAndLogError('批量下载失败', e, 'UI.Playlist.Local.Header')
+	})
src/app/download.tsx (2)

24-35: 下载任务列表不会自动刷新

staleTime: 0 意味着数据总是过期,但只有在组件重新挂载或手动调用 refetch 时才会重新获取。当下载任务状态变化(完成/失败)时,列表不会自动更新。

考虑添加轮询或监听 Orpheus 事件来刷新列表:

 const {
 	data: tasks,
 	isPending,
 	isError,
 	error,
+	refetch,
 } = useQuery({
 	queryKey: ['downloadTasks'],
 	queryFn: async () => {
 		return await Orpheus.getUncompletedDownloadTasks()
 	},
 	staleTime: 0,
+	refetchInterval: 3000, // 每3秒刷新一次
 })

或者通过 Orpheus 事件监听来触发 refetch


37-37: 变量命名可以更清晰

haveTrack 实际上是 Track | null 类型,但命名暗示它是布尔值。建议使用 currentTrack 更符合其类型。

-const haveTrack = useCurrentTrack()
+const currentTrack = useCurrentTrack()

然后在 line 84 使用 currentTrack 作为 truthy 检查。

src/features/playlist/local/components/LocalPlaylistItem.tsx (1)

5-5: 类型命名冲突

DownloadState@roitium/expo-orpheus 导入,但 src/types/core/downloadManagerStore.ts 中也定义了一个名为 DownloadState 的接口(包含 downloadsmaxConcurrentDownloads 字段)。虽然当前文件使用的是正确的枚举类型,但这种命名冲突可能会在其他地方导致混淆或误用。

建议在后续迭代中考虑重命名本地的 DownloadState 接口,例如改为 DownloadManagerState,以避免与 Orpheus 库的枚举类型混淆。

src/features/playlist/local/components/LocalTrackList.tsx (2)

109-109: 变量命名可改进

haveTrack 命名暗示是布尔值,但实际上是 Track | null 类型。建议重命名为 currentTrack 以更准确地反映其类型和用途。

-	const haveTrack = useCurrentTrack()
+	const currentTrack = useCurrentTrack()

然后更新第 178 行的使用:

-	paddingBottom: haveTrack ? 70 + insets.bottom : insets.bottom,
+	paddingBottom: currentTrack ? 70 + insets.bottom : insets.bottom,

112-113: 查询键稳定性

ids 数组在每次渲染时都会重新创建。由于 useBatchDownloadStatusqueryKey 包含 ids 数组,这可能导致在 tracks 引用变化但内容相同时触发不必要的重新查询。

考虑使用 useMemo 稳定化 ids 数组:

-	const ids = tracks.map((t) => t.uniqueKey)
+	const ids = useMemo(() => tracks.map((t) => t.uniqueKey), [tracks])
src/features/downloads/DownloadTaskItem.tsx (2)

32-44: 事件监听器依赖数组可优化

依赖数组中包含 task.state,这意味着每当状态变化时(例如从 QUEUED 到 DOWNLOADING),事件监听器都会被注销并重新注册。这是不必要的,因为 handler 内部已经处理了状态比较。

task.state 从依赖数组中移除,使用 ref 来访问最新状态:

+	const taskStateRef = useRef(task.state)
+	taskStateRef.current = task.state
+
 	useEffect(() => {
 		const handler = (e: ProgressEvent['progress:uniqueKey']) => {
 			sharedProgress.value = e.percent
-			if (e.state !== task.state) {
+			if (e.state !== taskStateRef.current) {
 				setTask((task) => ({ ...task, state: e.state }))
 			}
 		}
 		eventListner.on(`progress:${task.id}`, handler)

 		return () => {
 			eventListner.off(`progress:${task.id}`, handler)
 		}
-	}, [task.id, sharedProgress, task.state, setTask])
+	}, [task.id, sharedProgress, setTask])

142-144: 冗余的条件表达式

marginRight: task.state === DownloadState.FAILED ? 0 : 0 结果始终为 0,条件判断是多余的。

-					<View
-						style={{ marginRight: task.state === DownloadState.FAILED ? 0 : 0 }}
-					>
+					<View>
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0236ba8 and b71353d.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (45)
  • README.md (1 hunks)
  • app.config.ts (1 hunks)
  • docs/notes.md (1 hunks)
  • drizzle/0012_blushing_human_fly.sql (1 hunks)
  • drizzle/meta/0012_snapshot.json (1 hunks)
  • drizzle/meta/_journal.json (1 hunks)
  • drizzle/migrations.js (2 hunks)
  • index.js (1 hunks)
  • mise.toml (1 hunks)
  • package.json (3 hunks)
  • src/app/(tabs)/settings.tsx (6 hunks)
  • src/app/download.tsx (2 hunks)
  • src/app/playlist/local/[id].tsx (1 hunks)
  • src/app/playlist/remote/toview.tsx (4 hunks)
  • src/app/test.tsx (4 hunks)
  • src/components/NowPlayingBar.tsx (6 hunks)
  • src/components/modals/PlayerQueueModal.tsx (5 hunks)
  • src/components/modals/player/SleepTimerModal.tsx (4 hunks)
  • src/features/downloads/DownloadHeader.tsx (0 hunks)
  • src/features/downloads/DownloadTaskItem.tsx (7 hunks)
  • src/features/player/components/PlayerControls.tsx (4 hunks)
  • src/features/player/components/PlayerFunctionalMenu.tsx (4 hunks)
  • src/features/player/components/PlayerHeader.tsx (3 hunks)
  • src/features/playlist/local/components/LocalPlaylistHeader.tsx (3 hunks)
  • src/features/playlist/local/components/LocalPlaylistItem.tsx (4 hunks)
  • src/features/playlist/local/components/LocalTrackList.tsx (9 hunks)
  • src/features/playlist/local/hooks/useLocalPlaylistMenu.ts (3 hunks)
  • src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts (2 hunks)
  • src/features/playlist/remote/hooks/useRemotePlaylist.ts (2 hunks)
  • src/features/playlist/remote/search-result/hooks/useSearchInteractions.ts (3 hunks)
  • src/hooks/player/useBatchDownloadStatus.ts (1 hunks)
  • src/hooks/player/useCurrentTrack.ts (1 hunks)
  • src/hooks/player/useIsCurrentTrack.ts (1 hunks)
  • src/hooks/stores/useAppStore.ts (4 hunks)
  • src/hooks/stores/useDownloadManagerStore.ts (1 hunks)
  • src/lib/db/schema.ts (0 hunks)
  • src/lib/facades/sync.ts (2 hunks)
  • src/lib/services/artistService.ts (0 hunks)
  • src/lib/services/downloadService.ts (0 hunks)
  • src/lib/services/playlistService.ts (0 hunks)
  • src/lib/services/trackService.ts (0 hunks)
  • src/types/core/appStore.ts (0 hunks)
  • src/types/core/media.ts (0 hunks)
  • src/types/rntp.ts (0 hunks)
  • src/utils/player.ts (3 hunks)
💤 Files with no reviewable changes (9)
  • src/features/downloads/DownloadHeader.tsx
  • src/lib/db/schema.ts
  • src/lib/services/artistService.ts
  • src/types/core/media.ts
  • src/types/core/appStore.ts
  • src/lib/services/playlistService.ts
  • src/lib/services/downloadService.ts
  • src/types/rntp.ts
  • src/lib/services/trackService.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/notes.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/features/playlist/remote/search-result/hooks/useSearchInteractions.ts
🧰 Additional context used
🧬 Code graph analysis (23)
src/hooks/player/useBatchDownloadStatus.ts (1)
src/hooks/stores/usePlayerStore.ts (4)
  • uniqueKey (826-835)
  • state (401-436)
  • track (689-717)
  • state (830-834)
src/features/playlist/local/components/LocalPlaylistHeader.tsx (3)
src/lib/services/playlistService.ts (1)
  • playlistService (1040-1040)
src/utils/player.ts (1)
  • getInternalPlayUri (267-267)
src/hooks/stores/useModalStore.ts (1)
  • useModalStore (29-72)
src/components/modals/player/SleepTimerModal.tsx (1)
src/hooks/stores/usePlayerStore.ts (2)
  • durationInSeconds (66-76)
  • state (935-941)
src/features/player/components/PlayerHeader.tsx (2)
src/hooks/player/useBatchDownloadStatus.ts (1)
  • useBatchDownloadStatus (4-13)
src/types/core/downloadManagerStore.ts (1)
  • DownloadState (9-12)
src/features/playlist/local/components/LocalPlaylistItem.tsx (3)
src/types/core/downloadManagerStore.ts (1)
  • DownloadState (9-12)
src/hooks/player/useIsCurrentTrack.ts (1)
  • useIsCurrentTrack (4-60)
src/hooks/stores/usePlayerStore.ts (6)
  • state (401-436)
  • state (830-834)
  • currentTrackUniqueKey (624-650)
  • track (689-717)
  • uniqueKey (826-835)
  • state (254-262)
src/features/playlist/local/components/LocalTrackList.tsx (5)
src/types/core/downloadManagerStore.ts (1)
  • DownloadState (9-12)
src/features/playlist/remote/components/PlaylistItem.tsx (1)
  • TrackMenuItem (9-13)
src/features/playlist/remote/toview/components/Item.tsx (1)
  • TrackMenuItem (14-18)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/hooks/player/useBatchDownloadStatus.ts (1)
  • useBatchDownloadStatus (4-13)
index.js (2)
src/utils/player.ts (1)
  • finalizeAndRecordCurrentTrack (266-266)
src/hooks/stores/usePlayerStore.ts (3)
  • track (689-717)
  • reason (96-224)
  • index (719-824)
src/features/player/components/PlayerFunctionalMenu.tsx (3)
src/hooks/player/useBatchDownloadStatus.ts (1)
  • useBatchDownloadStatus (4-13)
src/utils/player.ts (1)
  • getInternalPlayUri (267-267)
src/types/core/downloadManagerStore.ts (1)
  • DownloadState (9-12)
src/lib/facades/sync.ts (3)
src/types/core/media.ts (1)
  • Track (64-64)
src/lib/errors/service.ts (1)
  • createValidationError (70-75)
src/lib/services/trackService.ts (5)
  • newTrack (140-188)
  • db (179-183)
  • track (338-351)
  • db (211-220)
  • db (523-527)
src/hooks/player/useIsCurrentTrack.ts (1)
src/hooks/stores/usePlayerStore.ts (9)
  • track (354-354)
  • track (689-717)
  • Track (83-88)
  • track (403-405)
  • currentTrackUniqueKey (624-650)
  • track (400-400)
  • state (401-436)
  • span (702-715)
  • uniqueKey (232-307)
src/utils/player.ts (8)
src/types/core/media.ts (1)
  • Track (64-64)
src/lib/errors/thirdparty/bilibili.ts (1)
  • BilibiliApiError (25-50)
src/lib/errors/player.ts (1)
  • PlayerError (10-17)
src/lib/services/trackService.ts (3)
  • trackService (819-819)
  • formatTrack (72-108)
  • title (506-517)
src/lib/db/schema.ts (1)
  • tracks (51-84)
src/utils/log.ts (1)
  • flatErrorMessage (111-129)
src/lib/config/queryClient.ts (1)
  • queryClient (9-45)
src/hooks/queries/db/track.ts (1)
  • trackKeys (11-32)
src/features/player/components/PlayerControls.tsx (2)
src/types/core/appStore.ts (1)
  • AppState (29-29)
src/hooks/stores/usePlayerStore.ts (6)
  • activeList (538-563)
  • state (401-436)
  • Sentry (532-565)
  • Sentry (454-530)
  • index (719-824)
  • currentTrackUniqueKey (624-650)
src/features/downloads/DownloadTaskItem.tsx (2)
src/types/core/downloadManagerStore.ts (2)
  • DownloadTask (1-7)
  • DownloadState (9-12)
src/hooks/stores/useDownloadManagerStore.ts (2)
  • ProgressEvent (5-13)
  • eventListner (14-14)
src/features/playlist/local/hooks/useLocalPlaylistMenu.ts (3)
src/utils/player.ts (2)
  • convertToOrpheusTrack (265-265)
  • getInternalPlayUri (267-267)
src/types/core/downloadManagerStore.ts (1)
  • DownloadState (9-12)
src/features/playlist/local/components/LocalPlaylistItem.tsx (1)
  • TrackMenuItem (12-17)
src/features/playlist/remote/hooks/useRemotePlaylist.ts (4)
src/types/core/media.ts (1)
  • BilibiliTrack (30-55)
src/lib/facades/sync.ts (1)
  • syncFacade (703-709)
src/utils/log.ts (1)
  • reportErrorToSentry (138-164)
src/hooks/stores/usePlayerStore.ts (9)
  • addToQueueParams (320-451)
  • state (401-436)
  • checkPlayerReady (339-449)
  • track (689-717)
  • Sentry (454-530)
  • currentTrackUniqueKey (624-650)
  • index (719-824)
  • Sentry (567-583)
  • logger (238-305)
src/app/playlist/local/[id].tsx (1)
src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts (1)
  • useLocalPlaylistPlayer (13-72)
src/hooks/player/useCurrentTrack.ts (3)
src/types/core/media.ts (1)
  • Track (64-64)
src/lib/services/trackService.ts (1)
  • trackService (819-819)
src/utils/error-handling.ts (1)
  • toastAndLogError (11-41)
src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts (5)
src/utils/mmkv.ts (1)
  • storage (7-7)
src/lib/services/playlistService.ts (1)
  • playlistService (1040-1040)
src/utils/error-handling.ts (1)
  • toastAndLogError (11-41)
src/lib/db/schema.ts (1)
  • tracks (51-84)
src/hooks/stores/usePlayerStore.ts (6)
  • addToQueueParams (320-451)
  • state (401-436)
  • index (719-824)
  • track (689-717)
  • currentTrackUniqueKey (624-650)
  • Sentry (532-565)
src/app/(tabs)/settings.tsx (1)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
app.config.ts (2)
expo-plugins/withAndroidGradleProperties.js (1)
  • config (15-33)
expo-plugins/withAndroidPlugin.js (1)
  • config (5-46)
drizzle/meta/0012_snapshot.json (1)
src/lib/services/trackService.ts (1)
  • db (211-220)
src/hooks/stores/useDownloadManagerStore.ts (1)
src/types/core/downloadManagerStore.ts (1)
  • DownloadState (9-12)
src/app/download.tsx (2)
src/types/core/downloadManagerStore.ts (1)
  • DownloadTask (1-7)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
🔇 Additional comments (38)
mise.toml (1)

9-12: 新增构建任务结构清晰,与现有任务保持一致。

buildpreview 任务的配置遵循现有的 buildprodbuilddev 任务的结构和模式:环境变量设置恰当,输出路径命名规范,与其他任务保持一致。NODE_ENV 设置为 "development" 符合预发布场景的要求。

请确认 EAS 构建系统中已配置 "preview" profile,使该任务能够正确执行。

src/components/modals/player/SleepTimerModal.tsx (2)

3-5: 导入语句符合 Orpheus 迁移需求

新增的 toast、Orpheus 和 useQuery 导入支持了从本地 store 向 Orpheus API 的迁移,实现合理。


98-103: 自定义输入的异步处理正确

onPress 改为 async 函数并 await handleSetTimer 是正确的做法,配合输入验证逻辑合理。假设 handleSetTimer 的错误处理问题得到修复(见前述评论),此处实现无问题。

README.md (1)

35-35: 文档更新准确。

播放库的更新已正确记录在技术栈部分,与 PR 的整体迁移保持一致。

app.config.ts (1)

103-104: 确认禁用发布构建优化的原因。

enableMinifyInReleaseBuildsenableShrinkResourcesInReleaseBuilds 设置为 false 会显著增加 APK 大小并可能影响性能。请确认:

  1. 这是 Orpheus 集成所必需的吗?
  2. 这是临时调试措施还是永久变更?
  3. 是否有其他解决方案可以在保持优化的同时支持 Orpheus?

如果这是永久变更,建议在 PR 描述或代码注释中记录原因,以便未来维护时参考。

package.json (3)

4-5: 版本号更新合理。

主版本号从 1.4.3 升级到 2.0.4 正确反映了这是一个包含破坏性变更的重大更新(迁移到 Orpheus)。


38-38: Verify package availability in npm registry.

The package @roitium/expo-orpheus@^0.4.7 was not found in the public npm registry as of December 2025. Confirm whether this is a private package, published to a different registry, or if the package name/version is correct. If it is private, ensure the build environment has appropriate access configured and document the registry source in the project.


99-99: Document the purpose of the react-native-logs fork.

The dependency uses a custom fork (v5.6.2) that does not exist in the official upstream (official npm: v5.3.0, official GitHub: v5.5.0). The custom versions v5.6.0, v5.6.1, and v5.6.2 exist only in the bbplayer-app fork and indicate divergence from upstream.

Add comments to package.json or documentation explaining:

  • What modifications this fork includes
  • Whether upstream contributions are planned
  • Why the custom fork is necessary
src/features/player/components/PlayerHeader.tsx (1)

37-39: 下载状态检查逻辑正确。

使用 Orpheus 的 DownloadState.COMPLETED 枚举值进行状态检查是正确的实现方式。

src/lib/facades/sync.ts (1)

84-104: 新方法实现良好。

addTrackToLocal 方法的实现符合最佳实践:

  1. 参数验证清晰(检查 artist 是否存在)
  2. 使用 ResultAsync 模式进行错误处理
  3. 正确链接了 artist 和 track 的创建流程
  4. 错误信息有意义

此方法为单曲本地持久化提供了良好的抽象。

src/features/playlist/local/hooks/useLocalPlaylistMenu.ts (2)

30-42: 播放下一首的实现正确。

重构后的 playNext 函数正确使用了 Orpheus API:

  1. 使用 convertToOrpheusTrack 进行类型转换
  2. 完善的错误处理和用户反馈
  3. 正确使用 Result 模式处理转换失败

100-108: 良好的防御性编程。

在调用 Orpheus.downloadTrack 之前验证内部播放 URI 是否存在,这是一个很好的防御性编程实践,避免了潜在的运行时错误。

src/features/player/components/PlayerControls.tsx (3)

16-23: Orpheus 状态集成正确。

使用 useIsPlayingusePlaybackStateuseQuery 获取播放器状态是正确的实现方式。将 shuffleModestaleTimegcTime 设置为 0 确保了实时同步。


29-39: AppState 监听器实现良好。

正确使用 useEffect 监听应用状态变化并同步 repeatMode,且在清理函数中移除了监听器,避免内存泄漏。


82-90: 随机播放切换实现正确。

使用 async/await 正确等待 Orpheus API 调用完成,然后通过 refetchShuffleMode 同步状态,确保了状态一致性。

drizzle/0012_blushing_human_fly.sql (1)

1-1: This review comment addresses a non-issue and can be dismissed.

The track_downloads table drop is not a breaking change affecting users. Both migrations 0011 (table creation) and 0012 (table drop) were introduced together in the initial project commit, with no prior releases. No production users will ever have data in this table when migration 0012 executes—the migrations are applied sequentially on first app startup.

Additionally, the table drop is intentional architectural change: the old SQLite-based download tracking (track_downloads) is being replaced by Orpheus's native download management, which the codebase already uses throughout (Orpheus.downloadTrack, Orpheus.getDownloadStatusByIds, etc.). No data migration is needed because Orpheus maintains its own download state independently.

Likely an incorrect or invalid review comment.

index.js (1)

1-22: Orpheus 事件监听整体合理,建议确认事件字段和时间单位

  • onPlayerError:直接使用 error.messageerror.code 做 toast,非常直观;建议确认 Orpheus 该事件的类型签名是否总是包含这两个字段,否则可在 UI 层对缺失字段做个兜底(例如将整个对象 JSON.stringify 一下)。
  • onTrackFinished:将 event.trackId / event.duration / event.finalPosition 直接传给 finalizeAndRecordCurrentTrack,逻辑上没问题,但需要确认:
    • trackId 是否就是之前 convertToOrpheusTrack 里设置的 uniqueKey
    • durationfinalPosition 的单位(秒/毫秒)与 finalizeAndRecordCurrentTrack 中按“秒”计算的逻辑保持一致。

这些如果在 Orpheus SDK 文档里已经保证,可以在注释中简单说明一下,方便以后维护。

drizzle/meta/_journal.json (1)

88-95: Drizzle 迁移日志条目看起来一致

新增的 idx: 12 / tag: "0012_blushing_human_fly" 与前面条目的结构和版本号保持一致,能够正常被 drizzle/migrations.js 中的 journal 使用,没有问题。

drizzle/migrations.js (1)

15-34: 新迁移 m0012 注册方式正确

m0012 已正确引入并加入 migrations 映射,形式与之前的迁移保持一致,可以被现有迁移管线正常执行。

建议确认对应的 0012_blushing_human_fly.sql 与 snapshot/_journal 一致(目前从其它文件看是删除 track_downloads 表,符合整体改动方向)。

drizzle/meta/0012_snapshot.json (1)

1-516: 数据库快照与迁移目标一致

该 snapshot 展示的表结构(artists / bilibili_metadata / local_metadata / playlists / playlist_tracks / tracks)中已经不再包含 track_downloads 表,和本次新增的 0012 迁移(删除下载表)目标一致,字段与索引结构也和现有 TypeScript schema 片段对得上,看起来是由 Drizzle 正常生成的快照。

src/app/playlist/local/[id].tsx (1)

126-126: 接口变更看起来正确

useLocalPlaylistPlayer 现在接收 playlistId 而非播放列表内容数组,hook 内部负责获取和过滤轨道数据。这是一个更清晰的职责分离。

src/components/NowPlayingBar.tsx (3)

79-88: LGTM! Orpheus 集成和状态派生逻辑清晰

从本地 store 迁移到 Orpheus hooks(useIsPlayingusePlaybackState)的实现正确。finalPlayingIndicator 的派生逻辑很好地处理了 BUFFERING 状态,为用户提供加载反馈。


105-111: scheduleOnRN 参数传递方式验证

这里通过 scheduleOnRN 的第二个参数传递 isPlaying 状态,确保回调执行时使用正确的值。这种模式避免了闭包陷阱,实现正确。


176-176: 良好的空值回退处理

title 添加了 '未知曲目' 的回退值,与下方 artist 的 '未知' 回退保持一致,提升了用户体验。

src/app/(tabs)/settings.tsx (1)

162-163: The timing concern about synchronous initialization is not applicable here.

Orpheus.restorePlaybackPositionEnabled is a property on a native module wrapper that is guaranteed to be available synchronously after app initialization. The settings component only renders after the root layout completes its setup, so this synchronous read is safe. This pattern is already used consistently throughout the codebase (e.g., Orpheus.setBilibiliCookie() is called synchronously during store hydration in useAppStore.ts). No changes needed.

Likely an incorrect or invalid review comment.

src/features/player/components/PlayerFunctionalMenu.tsx (1)

134-135: 定时关闭功能已禁用

根据 PR 目标,定时停止播放功能尚未完成。当前以 toast 提示用户功能未实现是合理的临时处理。

src/hooks/stores/useAppStore.ts (1)

87-88: Cookie 同步到 Orpheus 的实现正确

在状态更新后同步 cookie 到 Orpheus,逻辑顺序正确,确保了 JS 层和原生层的状态一致性。

src/app/playlist/remote/toview.tsx (2)

171-182: "播放全部"按钮的点击事件被禁用

onClickMainButton 被注释掉,导致 PlaylistHeader 中的"播放全部"按钮无法响应点击。这似乎是迁移过程中的临时状态。

请确认是否需要实现批量播放功能,或者这是计划中的后续工作。


98-108: No issues found. toastAndLogError does not include Sentry reporting — it only displays a toast notification and logs locally. The separate reportErrorToSentry call is appropriate and not redundant.

Likely an incorrect or invalid review comment.

src/features/playlist/local/components/LocalPlaylistHeader.tsx (1)

204-204: LGTM

按钮点击处理正确封装。

src/app/download.tsx (1)

41-64: 加载和错误状态处理正确

Loading 和 Error 状态的 UI 处理清晰,用户体验良好。

src/features/playlist/local/hooks/useLocalPlaylistPlayer.ts (2)

17-44: LGTM!

将 hook 签名从 tracks: Track[] 改为 playlistId: number 的重构是合理的。通过 playlistService.getPlaylistTracks 获取最新数据可以避免使用过期的 tracks 列表,错误处理也很完善。


24-26: 不存在空值访问风险

由于 Track 类型是一个有区分的联合类型 (BilibiliTrack | LocalTrack),当 item.source === 'bilibili' 时,TypeScript 类型系统已经将其类型缩小为 BilibiliTrackBilibiliTrack 接口中 bilibiliMetadata 是必需属性(非可选),且 videoIsValid 是其必需的布尔属性。因此,条件检查 item.source === 'bilibili' ? item.bilibiliMetadata.videoIsValid : true 是类型安全的,不存在空值访问风险。

src/features/playlist/local/components/LocalPlaylistItem.tsx (2)

57-94: LGTM!

renderDownloadStatus 的重构清晰地将下载状态映射到对应的图标和颜色。依赖数组正确地包含了所有使用的值。


53-53: 性能注意事项

每个 TrackListItem 实例都会调用 useIsCurrentTrack,该 hook 会创建独立的 Orpheus 事件监听器。对于较大的播放列表,这可能导致大量并发的事件订阅。

建议验证在大型播放列表(如 100+ 曲目)场景下的性能表现。如果存在问题,可以考虑在父组件维护当前曲目状态并通过 props 传递。

src/features/playlist/local/components/LocalTrackList.tsx (1)

205-220: LGTM!

菜单项的渲染逻辑正确,downloadState 被正确地传递给 trackMenuItems 函数以支持基于下载状态的上下文菜单。

src/components/modals/PlayerQueueModal.tsx (2)

113-122: LGTM!

查询配置合理:staleTime: 0gcTime: 0 确保队列数据始终是最新的,enabled: isVisible 避免在模态框关闭时进行不必要的查询。


27-99: LGTM!

TrackItem 组件正确地适配了新的 OrpheusTrack 类型,使用 index 参数进行曲目切换和移除操作是符合 Orpheus API 设计的。

Comment thread src/app/download.tsx
Comment thread src/components/modals/player/SleepTimerModal.tsx
Comment thread src/features/downloads/DownloadTaskItem.tsx Outdated
Comment thread src/features/player/components/PlayerFunctionalMenu.tsx Outdated
Comment thread src/features/player/components/PlayerHeader.tsx Outdated
Comment thread src/features/playlist/local/components/LocalPlaylistHeader.tsx
Comment thread src/hooks/player/useCurrentTrack.ts Outdated
Comment thread src/hooks/player/useIsCurrentTrack.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (2)
src/features/player/components/PlayerFunctionalMenu.tsx (1)

89-110: Orpheus.downloadTrack 缺少 try/catch:成功 toast 可能误报
这点在旧 commit 的 review 里已经提过,但当前实现仍然会在下载失败时显示“已添加到下载队列”。

- await Orpheus.downloadTrack({
-   id: currentTrack.uniqueKey,
-   url: url,
-   title: currentTrack.title,
-   artist: currentTrack.artist?.name,
-   artwork: currentTrack.coverUrl ?? undefined,
-   duration: currentTrack.duration,
- })
- toast.info('已添加到下载队列')
+ try {
+   await Orpheus.downloadTrack({
+     id: currentTrack.uniqueKey,
+     url,
+     title: currentTrack.title,
+     artist: currentTrack.artist?.name,
+     artwork: currentTrack.coverUrl ?? undefined,
+     duration: currentTrack.duration,
+   })
+   toast.info('已添加到下载队列')
+ } catch (e) {
+   toast.error('添加到下载队列失败')
+ }
@roitium/expo-orpheus 的 Orpheus.downloadTrack 在失败时是 throw 异常还是返回错误码/Result?是否有“已在队列中”之类的可区分错误?
src/components/modals/player/SleepTimerModal.tsx (1)

45-55: 必须添加错误处理和等待 refetch 完成

当前实现存在以下严重问题:

  1. 无错误处理:如果 Orpheus.setSleepTimerOrpheus.cancelSleepTimer 失败,用户不会收到任何提示,弹窗仍会关闭,导致用户误以为操作成功
  2. 状态不一致风险:使用 void refetch() 不等待数据更新完成就关闭弹窗,可能导致 UI 显示的状态与实际状态不同步

应用以下修改添加错误处理并等待状态同步:

 const handleSetTimer = async (minutes: number) => {
+  try {
     await Orpheus.setSleepTimer(minutes * 60 * 1000)
-    void refetch()
+    await refetch()
     close('SleepTimer')
+  } catch (error) {
+    toast.error('设置定时器失败,请重试')
+    console.error('Sleep timer error:', error)
+  }
 }

 const handleCancelTimer = async () => {
+  try {
     await Orpheus.cancelSleepTimer()
-    void refetch()
+    await refetch()
     close('SleepTimer')
+  } catch (error) {
+    toast.error('取消定时器失败,请重试')
+    console.error('Cancel sleep timer error:', error)
+  }
 }

注意:需要在文件顶部添加 toast 导入(如果尚未导入)。

🧹 Nitpick comments (5)
package.json (1)

99-99: git+https 依赖 react-native-logs:建议在 package.json 中显式指定 commit SHA

pnpm-lock.yaml 已正确提交并包含该依赖的 commit hash(b534efdc...),提供了可复现性保证。但建议在 package.json 中直接使用 commit SHA 而非 tag 引用,例如:

"react-native-logs": "git+https://github.com/bbplayer-app/react-native-logs.git#b534efdc6022ba04d7b98e4dae0a1f8864cc1fb4"

这样可避免 tag 被意外移动或重写的风险,使声明与锁文件一致。仓库已验证可访问,CI 无权限问题。

src/app/(tabs)/settings.tsx (1)

36-36: 变量命名与实际类型不一致。

useCurrentTrack() 返回的是 Track | null,而变量名 haveTrack 暗示这是一个布尔值。虽然在第 45 行的条件判断中可以正常工作(利用了 JS 的 truthiness),但命名会让阅读代码的人产生困惑。

-	const haveTrack = useCurrentTrack()
+	const currentTrack = useCurrentTrack()

并相应更新第 45 行的引用:

-				paddingBottom: haveTrack ? 70 : insets.bottom,
+				paddingBottom: currentTrack ? 70 : insets.bottom,
src/features/player/components/PlayerFunctionalMenu.tsx (1)

89-96: 精简 menu 关闭逻辑 + 改善用户提示文案,并让 title 判断更直观
setMenuVisible(false)!currentTrack 分支和后面重复调用;另外“为什么 currentTrack 不存在?”不适合作为面向用户的错误提示。title 也建议基于 trackId 计算,避免空 key。

 onPress={async () => {
-  if (!currentTrack) {
-    setMenuVisible(false)
-    toast.error('为什么 currentTrack 不存在?')
-    return
-  }
-  setMenuVisible(false)
+  setMenuVisible(false)
+  if (!currentTrack) {
+    toast.error('当前未在播放任何音频')
+    return
+  }
   const url = getInternalPlayUri(currentTrack)
 title={
-  downloadStatus?.[currentTrack?.uniqueKey ?? ''] === DownloadState.COMPLETED
+  (currentTrack?.uniqueKey &&
+    downloadStatus?.[currentTrack.uniqueKey] === DownloadState.COMPLETED)
     ? '重新下载音频'
     : '下载音频'
 }

Also applies to: 111-116

src/components/modals/player/SleepTimerModal.tsx (2)

14-21: 考虑调整缓存策略以平衡实时性与性能

当前 gcTime: 0staleTime: 0 的配置会导致组件每次重新挂载或窗口重新聚焦时都触发数据重新获取。由于组件内已有 1 秒间隔的 useEffect(Line 27)持续计算并更新剩余时间,UI 的实时性已经得到保障。

建议设置适度的 staleTime(如 5000-10000 毫秒),以减少不必要的查询次数,同时保持足够的数据新鲜度。

 const { data: sleepTimerEndAt, refetch } = useQuery({
   queryFn: async () => {
     return await Orpheus.getSleepTimerEndTime()
   },
   queryKey: ['sleepTimerEndAt'],
-  gcTime: 0,
-  staleTime: 0,
+  staleTime: 5000, // 5秒内认为数据新鲜,避免频繁重新获取
 })

96-101: 考虑为无效输入提供用户反馈

当前逻辑对无效输入(非数字、负数、零)采用静默忽略策略。建议在输入无效时向用户提供明确反馈,避免用户疑惑为何点击"设置"后无响应。

 onPress={async () => {
   const minutes = parseInt(customMinutes, 10)
   if (!isNaN(minutes) && minutes > 0) {
     await handleSetTimer(minutes)
+  } else {
+    toast.error('请输入有效的分钟数(大于 0)')
   }
 }}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b71353d and f12ed3f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (13)
  • package.json (3 hunks)
  • src/app/(tabs)/settings.tsx (5 hunks)
  • src/app/playlist/remote/collection/[id].tsx (0 hunks)
  • src/app/playlist/remote/favorite/[id].tsx (0 hunks)
  • src/app/playlist/remote/multipage/[bvid].tsx (0 hunks)
  • src/app/playlist/remote/search-result/fav/[query].tsx (0 hunks)
  • src/app/playlist/remote/search-result/global/[query].tsx (0 hunks)
  • src/app/playlist/remote/toview.tsx (4 hunks)
  • src/app/playlist/remote/uploader/[mid].tsx (0 hunks)
  • src/components/modals/player/SleepTimerModal.tsx (4 hunks)
  • src/features/player/components/PlayerFunctionalMenu.tsx (3 hunks)
  • src/hooks/stores/useAppStore.ts (4 hunks)
  • src/types/core/appStore.ts (0 hunks)
💤 Files with no reviewable changes (7)
  • src/app/playlist/remote/multipage/[bvid].tsx
  • src/app/playlist/remote/collection/[id].tsx
  • src/types/core/appStore.ts
  • src/app/playlist/remote/search-result/global/[query].tsx
  • src/app/playlist/remote/search-result/fav/[query].tsx
  • src/app/playlist/remote/uploader/[mid].tsx
  • src/app/playlist/remote/favorite/[id].tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/app/playlist/remote/toview.tsx
🧰 Additional context used
🧬 Code graph analysis (3)
src/components/modals/player/SleepTimerModal.tsx (1)
src/hooks/stores/usePlayerStore.ts (2)
  • durationInSeconds (66-76)
  • state (935-941)
src/app/(tabs)/settings.tsx (3)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-79)
src/hooks/stores/usePlayerStore.ts (8)
  • span (702-715)
  • state (401-436)
  • track (689-717)
  • Sentry (454-530)
  • index (719-824)
  • state (935-941)
  • currentTrackUniqueKey (624-650)
  • track (403-405)
src/components/NowPlayingBar.tsx (2)
  • NowPlayingBar (72-215)
  • state (78-78)
src/features/player/components/PlayerFunctionalMenu.tsx (4)
src/hooks/player/useBatchDownloadStatus.ts (1)
  • useBatchDownloadStatus (4-13)
src/utils/player.ts (1)
  • getInternalPlayUri (267-267)
src/types/core/downloadManagerStore.ts (1)
  • DownloadState (9-12)
src/hooks/stores/usePlayerStore.ts (2)
  • uniqueKey (826-835)
  • track (689-717)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (9)
package.json (2)

4-5: 版本号与 versionCode 提升:请确认与 Expo/原生配置一致
version / versionCode 的提升没问题,但请同步核对 app.config.* / app.json、Android versionCode、iOS buildNumber(若有)一致性,避免商店构建号冲突或 OTA 版本判断异常。


38-38: 引入 @roitium/expo-orpheus:请核对 peerDependencies/原生集成要求
这是播放器迁移的关键依赖,建议确认它对 Expo SDK / React Native 版本、是否需要 config plugin、以及 Android(Media3)/iOS 侧的额外配置在本 PR 中都已覆盖。

src/app/(tabs)/settings.tsx (2)

162-166: 状态同步依赖于 Orpheus 初始化时机。

本地状态从 Orpheus.restorePlaybackPositionEnabledOrpheus.loudnessNormalizationEnabled 同步初始化。如果 Orpheus 模块在组件挂载时尚未完成初始化,或者这些值在其他地方被修改,UI 可能显示过时的状态。

如果这些设置只在此页面修改,当前实现是可以接受的。否则,考虑使用 useEffect 监听 Orpheus 状态变化以保持同步。


255-260: [rewritten review comment]
[classification tag]

src/components/modals/player/SleepTimerModal.tsx (2)

25-43: LGTM!

剩余时间计算逻辑正确,正确处理了定时器更新、边界条件和清理工作。


3-4: 导入语句正确,无需修改。

这两行导入是代码所需的依赖项,已在 package.json 中声明为 @roitium/expo-orpheus@^0.5.0Orpheus.getSleepTimerEndTime()Orpheus.setSleepTimer()Orpheus.cancelSleepTimer() 等方法在代码中被正常调用(第 16、46、52 行),表明这些 API 在 v0.5.0 中可用且运作正常。导入模式与整个代码库中的其他用法一致。

src/hooks/stores/useAppStore.ts (3)

76-107: Orpheus 同步缺少错误处理,且清理 cookie 未同步到原生层

Line 86 和 98 的 setBilibiliCookie / updateBilibiliCookie 直接调用 Orpheus.setBilibiliCookie(...) 而不处理其可能的异常,会导致 JS 状态与原生层不一致。同时 line 103-107 的 clearBilibiliCookie 没有清理 Orpheus,风险更高。

需要确认 Orpheus.setBilibiliCookie 的 API 契约(返回类型、错误处理方式)后,为这三处添加统一的错误处理,确保状态同步失败时的行为一致。参考 src/app/_layout.tsx 中已有的 try/catch 模式。


241-246: Hydration 仅在有 cookie 时设置,无 cookie 时不清空;clearBilibiliCookie 也未同步到原生层;日志误导

已验证存在多个关键缺陷:

  1. Hydration 不清空:用户登出后 bilibiliCookie 为 null,hydration 时未调用 Orpheus API 清空,原生层仍保留旧 cookie,可能继续带 cookie 发请求。

  2. clearBilibiliCookie 未同步:该方法(lines 103-107)仅清空本地状态,不调用任何 Orpheus API,用户登出时原生层的 cookie 不会被清空。

  3. 日志误导:无论是否存在 cookie,都打印"应用启动时自动设置 cookie 到 orpheus",应根据是否有 cookie 区分。

  4. 不对称的 API 调用setBilibiliCookieupdateBilibiliCookie 都调用 Orpheus.setBilibiliCookie(),而 clearBilibiliCookie 未调用对应的清空方法。

建议:

  • Hydration 完成后无论是否存在 cookie,都同步一次(存在则 set,不存在则 clear)
  • clearBilibiliCookie 调用对应的 Orpheus API 清空
  • 日志与实际行为一致

还需确认 Orpheus.setBilibiliCookie('') 是否等价于清空;若库提供专门的 clear API(如 clearBilibiliCookie() 等),应优先使用专用方法。


7-7: 移除不适用的 Web 兼容性检查 - 此项目是 React Native/Expo 应用,不涉及 Web 构建

该项目为 Expo 原生应用,仅包含 iOS 和 Android 构建目标(见 package.json),不存在 Web 构建场景。@roitium/expo-orpheus 作为原生模块,在此上下文中使用是安全的,无需平台守护。

建议关注的实际问题:

  1. setBilibiliCookie()updateBilibiliCookie() 中的 Orpheus.setBilibiliCookie() 调用缺少错误处理
  2. clearBilibiliCookie() 未同步调用 Orpheus.clearBilibiliCookie(),可能导致原生层仍保留旧 cookie

Comment thread package.json Outdated
Comment thread src/app/(tabs)/settings.tsx
Comment thread src/features/player/components/PlayerFunctionalMenu.tsx Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (5)
src/features/player/components/PlayerFunctionalMenu.tsx (2)

28-31: 下载状态查询逻辑已修复,LGTM!

正确处理了 currentTrack 为空的情况,避免传递 [''] 触发不必要的原生层调用。


91-120: 错误处理已完善,LGTM!

下载流程现在包含完整的错误处理:

  • URL 获取失败时显示错误提示
  • Orpheus.downloadTrack 调用包裹在 try-catch 中
  • 失败时通过 toastAndLogError 记录上下文信息

代码结构清晰,错误边界处理得当。

src/features/player/components/PlayerHeader.tsx (1)

35-40: 避免用 '' 作为 downloadStatus 索引 key,改为显式判空后再比较

建议提取 currentKey 并在存在时再做状态比较,避免 downloadStatus?.[''] 这种“看似安全但语义不明”的访问。

 export function PlayerHeader({
   onMorePress,
   index,
 }: {
   onMorePress: () => void
   index: number
 }) {
   const router = useRouter()
   const currentTrack = useCurrentTrack()
+  const currentKey = currentTrack?.uniqueKey
   const { data: downloadStatus } = useBatchDownloadStatus(
-    currentTrack?.uniqueKey ? [currentTrack.uniqueKey] : [],
+    currentKey ? [currentKey] : [],
   )

   return (
@@
         {index === 1
           ? (currentTrack?.title ?? '正在播放')
-          : downloadStatus?.[currentTrack?.uniqueKey ?? ''] ===
-              DownloadState.COMPLETED
+          : (currentKey &&
+              downloadStatus?.[currentKey] === DownloadState.COMPLETED)
             ? '正在播放 (已缓存)'
             : '正在播放'}
src/features/downloads/DownloadTaskItem.tsx (1)

137-148: 错误处理已添加

之前的审查指出 Orpheus.downloadTrackOrpheus.removeDownload 缺少错误处理。现在已正确添加了 try-catch 并使用 toastAndLogError 通知用户。

Also applies to: 158-168

src/components/modals/player/SleepTimerModal.tsx (1)

57-61: 缺少错误处理可能导致误导性的成功提示

handleCancelTimer 没有 try-catch 错误处理。如果 Orpheus.cancelSleepTimer() 失败(网络问题、原生层错误等),代码仍会显示"取消定时器成功"的提示并关闭弹窗,但定时器实际上可能仍在运行。这会误导用户,可能导致播放器意外停止。

应用以下修复添加错误处理:

 const handleCancelTimer = async () => {
+  try {
     await Orpheus.cancelSleepTimer()
     toast.success('取消定时器成功')
     close('SleepTimer')
+  } catch (e) {
+    toastAndLogError('取消定时器失败', e, 'Modal.SleepTimer')
+  }
 }

更进一步,建议与 handleSetTimer 一样,也采用 useMutation 模式以保持一致性并支持跨组件状态同步:

+const cancelTimerMutation = useMutation({
+  mutationFn: async () => {
+    await Orpheus.cancelSleepTimer()
+  },
+  onSuccess: () => {
+    queryClient.invalidateQueries({ queryKey: ['sleepTimerEndAt'] })
+    toast.success('取消定时器成功')
+    close('SleepTimer')
+  },
+  onError: (e) => {
+    toastAndLogError('取消定时器失败', e, 'Modal.SleepTimer')
+  },
+})

 const handleCancelTimer = async () => {
-  await Orpheus.cancelSleepTimer()
-  toast.success('取消定时器成功')
-  close('SleepTimer')
+  cancelTimerMutation.mutate()
 }
🧹 Nitpick comments (3)
src/features/downloads/DownloadTaskItem.tsx (1)

33-45: task.state 在依赖数组中会导致不必要的监听器重新注册

事件监听器的注册和注销只依赖于 task.id,但 task.state 被包含在依赖数组中。这意味着每次状态变化时,监听器都会被移除并重新添加,这是不必要的开销。

 	useEffect(() => {
 		const handler = (e: ProgressEvent['progress:uniqueKey']) => {
 			sharedProgress.value = e.percent
 			if (e.state !== task.state) {
 				setTask((task) => ({ ...task, state: e.state }))
 			}
 		}
 		eventListner.on(`progress:${task.id}`, handler)

 		return () => {
 			eventListner.off(`progress:${task.id}`, handler)
 		}
-	}, [task.id, sharedProgress, task.state, setTask])
+	}, [task.id, sharedProgress, setTask])

由于 handler 内部通过 setTask 的函数式更新来比较状态,不需要将 task.state 作为外部依赖。

src/hooks/player/useCurrentTrack.ts (1)

52-54: 在事件回调里增加 “已卸载” 短路,避免卸载瞬间仍启动新异步任务
当前依赖 fetchAndUpdate() 内部的 isMounted 防护是安全的,但仍会做一次无意义的异步启动。

- const sub = Orpheus.addListener('onTrackStarted', () => {
-   void fetchAndUpdate()
- })
+ const sub = Orpheus.addListener('onTrackStarted', () => {
+   if (!isMounted) return
+   void fetchAndUpdate()
+ })

Also applies to: 56-59

src/components/modals/player/SleepTimerModal.tsx (1)

47-55: 错误处理已添加,但建议使用 react-query mutation 模式

相比之前的实现,已添加了 try-catch 错误处理和用户提示,这很好。

不过,当前模式在成功设置定时器后直接关闭弹窗,没有刷新查询数据。虽然弹窗内的陈旧数据不会被看到(因为已关闭),但如果应用的其他部分也查询 sleepTimerEndAt,它们不会自动更新。

建议采用 react-query 的标准 mutation 模式,以便自动同步所有查询实例:

+import { useMutation, useQueryClient } from '@tanstack/react-query'

 const SleepTimerModal = () => {
+  const queryClient = useQueryClient()
   const close = useModalStore((state) => state.close)
   
+  const setTimerMutation = useMutation({
+    mutationFn: async (minutes: number) => {
+      await Orpheus.setSleepTimer(minutes * 60 * 1000)
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['sleepTimerEndAt'] })
+      toast.success('设置定时器成功')
+      close('SleepTimer')
+    },
+    onError: (e) => {
+      toastAndLogError('设置定时器失败', e, 'Modal.SleepTimer')
+    },
+  })

   const handleSetTimer = async (minutes: number) => {
-    try {
-      await Orpheus.setSleepTimer(minutes * 60 * 1000)
-      toast.success('设置定时器成功')
-      close('SleepTimer')
-    } catch (e) {
-      toastAndLogError('设置定时器失败', e, 'Modal.SleepTimer')
-    }
+    setTimerMutation.mutate(minutes)
   }

这样可以确保所有使用 ['sleepTimerEndAt'] 查询键的组件都会自动刷新。

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f12ed3f and 74767d5.

📒 Files selected for processing (10)
  • package.json (3 hunks)
  • src/app/download.tsx (2 hunks)
  • src/components/modals/player/SleepTimerModal.tsx (4 hunks)
  • src/features/downloads/DownloadTaskItem.tsx (7 hunks)
  • src/features/player/components/PlayerFunctionalMenu.tsx (3 hunks)
  • src/features/player/components/PlayerHeader.tsx (3 hunks)
  • src/features/playlist/remote/search-result/hooks/useSearchInteractions.ts (2 hunks)
  • src/hooks/player/useCurrentTrack.ts (1 hunks)
  • src/hooks/player/useIsCurrentTrack.ts (1 hunks)
  • src/hooks/player/useTrackProgress.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/hooks/player/useTrackProgress.ts
  • src/features/playlist/remote/search-result/hooks/useSearchInteractions.ts
  • src/hooks/player/useIsCurrentTrack.ts
  • package.json
🧰 Additional context used
🧬 Code graph analysis (4)
src/components/modals/player/SleepTimerModal.tsx (1)
src/hooks/stores/usePlayerStore.ts (6)
  • durationInSeconds (66-76)
  • logger (674-685)
  • Sentry (668-687)
  • state (935-941)
  • Sentry (454-530)
  • state (679-683)
src/features/player/components/PlayerHeader.tsx (3)
src/hooks/player/useBatchDownloadStatus.ts (1)
  • useBatchDownloadStatus (4-13)
src/types/core/downloadManagerStore.ts (1)
  • DownloadState (9-12)
src/lib/services/trackService.ts (1)
  • createOrUpdateTrackDownloadRecord (833-860)
src/features/downloads/DownloadTaskItem.tsx (2)
src/types/core/downloadManagerStore.ts (2)
  • DownloadTask (1-7)
  • DownloadState (9-12)
src/hooks/stores/useDownloadManagerStore.ts (2)
  • ProgressEvent (5-13)
  • eventListner (14-14)
src/app/download.tsx (3)
src/types/core/downloadManagerStore.ts (1)
  • DownloadTask (1-7)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-63)
src/lib/config/queryClient.ts (1)
  • queryClient (9-45)
🔇 Additional comments (9)
src/features/player/components/PlayerFunctionalMenu.tsx (1)

121-126: 标题逻辑正确。

根据下载状态正确切换按钮文案,已完成时显示"重新下载音频",否则显示"下载音频"。

src/features/player/components/PlayerHeader.tsx (1)

1-6: 无需修改 —— 导入和类型使用正确

useBatchDownloadStatus 返回的 data 属性为 Record<string, DownloadState> 类型,与 @roitium/expo-orpheus 中的 DownloadState 枚举一致。代码中使用 downloadStatus?.[currentTrack?.uniqueKey ?? ''] === DownloadState.COMPLETED 的模式完全正确。

虽然存在命名冲突(src/types/core/downloadManagerStore.ts 也导出同名的 DownloadState 接口),但此处导入的是 @roitium/expo-orpheus 中的正确类型,不会造成问题。

src/app/download.tsx (1)

74-79: 清除后刷新列表的问题已修复

之前的审查指出调用 clearUncompletedDownloadTasks() 后列表不会自动刷新。现在已正确添加了 queryClient.invalidateQueries() 调用。

src/features/downloads/DownloadTaskItem.tsx (1)

26-28: 使用 useRecyclingState 管理任务状态是合适的选择

配合 FlashList 的回收机制,使用 useRecyclingState 可以在列表项被回收复用时正确重置状态。initTask.id 作为依赖项确保了当任务 ID 变化时状态会被重新初始化。

src/hooks/player/useCurrentTrack.ts (2)

7-47: 竞态/卸载保护实现到位(requestId + isMounted)
lastRequestIdRef + 多处 await 后校验,能有效避免快速切歌导致的乱序更新;卸载后也不会再 setTrack


18-28: 所有核心 API 约定已在代码中验证无误,无需修改:

  1. onTrackStarted 事件名正确 — 在 useCurrentTrack.tsuseIsCurrentTrack.ts 中一致使用
  2. Orpheus.addListener() 返回值包含 remove() 方法 — 第 58 行成功调用 sub.remove()
  3. currentTrack.iduniqueKey 语义一致src/utils/player.tsconvertToOrpheusTrack() 明确设置 orpheusTrack.id = track.uniqueKey,保证 Orpheus 返回的 currentTrack.id 就是 trackService.getTrackByUniqueKey() 期望的值

当前实现可以正常获取 internalTrack,无风险。

src/components/modals/player/SleepTimerModal.tsx (3)

2-6: 导入合理,支持新的 Orpheus 集成

新增的导入项(toastAndLogError、toast、Orpheus、useQuery)都有明确用途,支持从本地 store 迁移到 Orpheus 原生 API 的架构变更。


102-107: 正确地将自定义输入处理改为异步

将按钮的 onPress 改为 async 函数并等待 handleSetTimer 完成,确保了定时器设置操作的正确执行顺序。输入验证逻辑也很恰当。


16-23: useQuery 配置已适配此场景,无需改进

当前实现中 sleepTimerEndAt 仅在 SleepTimerModal 内部使用,不存在与其他组件共享状态的情况。查询配置 gcTime: 0, staleTime: 0 搭配 handleSetTimerhandleCancelTimer 后立即关闭弹窗的逻辑,已经确保下次打开时获取最新数据。无需额外的 invalidateQueries 调用。

Comment thread src/app/download.tsx
Comment thread src/features/downloads/DownloadTaskItem.tsx Outdated
Comment thread src/features/player/components/PlayerHeader.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
src/app/download.tsx (1)

38-38: haveTrack 命名可改进

useCurrentTrack() 返回 Track | null,这里用于布尔判断是正确的,但变量命名 haveTrack 暗示布尔值。建议改为 currentTrack 以更准确反映其类型。

-	const haveTrack = useCurrentTrack()
+	const currentTrack = useCurrentTrack()

并在使用处:

-	paddingBottom: haveTrack ? 70 + insets.bottom : insets.bottom,
+	paddingBottom: currentTrack ? 70 + insets.bottom : insets.bottom,
src/features/downloads/DownloadTaskItem.tsx (1)

33-45: useEffect 依赖数组包含 task.state 可能导致不必要的重订阅

handler 内部调用 setTask 更新 task.state 时,由于 task.state 在依赖数组中,会触发 effect 重新执行并重新订阅事件。虽然功能上正确(因为会先 off 再 on),但会产生不必要的订阅/取消订阅操作。

建议移除 task.state 依赖,使用 ref 或在 handler 内部获取最新状态:

 useEffect(() => {
 	const handler = (e: ProgressEvent['progress:uniqueKey']) => {
 		sharedProgress.value = e.percent
-		if (e.state !== task.state) {
-			setTask((task) => ({ ...task, state: e.state }))
-		}
+		setTask((prevTask) => {
+			if (e.state !== prevTask.state) {
+				return { ...prevTask, state: e.state }
+			}
+			return prevTask
+		})
 	}
 	eventListner.on(`progress:${task.id}`, handler)

 	return () => {
 		eventListner.off(`progress:${task.id}`, handler)
 	}
-}, [task.id, sharedProgress, task.state, setTask])
+}, [task.id, sharedProgress, setTask])

这样可以避免因 task.state 变化导致的重复订阅。

src/hooks/player/useBatchDownloadStatus.ts (1)

4-14: 考虑 queryKey 稳定性和缓存策略。

当前实现存在两个潜在问题:

  1. queryKey 引用不稳定(Line 6):如果 ids 数组在每次渲染时都是新引用,即使内容相同,也会导致缓存失效和不必要的重新获取。建议调用方使用 useMemo 稳定 ids 引用,或在 hook 内部对 ids 进行序列化处理。

  2. 完全禁用缓存(Lines 10-11):staleTime: 0gcTime: 0 意味着数据永不缓存,可能导致频繁的 API 调用。根据相关代码片段(如 src/hooks/queries/db/track.ts 中的 usePlayCountLeaderBoardPaginated 设置 gcTime: 0),完全禁用缓存可能是为了避免加载大量缓存数据导致卡顿。但对于下载状态这种频繁变化的数据,可以考虑设置较短的 staleTime(如 1-5 秒)以减少不必要的请求。

可以考虑以下改进:

 export function useBatchDownloadStatus(ids: string[]) {
 	return useQuery({
-		queryKey: ['batchDownloadStatus', ids],
+		queryKey: ['batchDownloadStatus', ids.join(',')],
 		queryFn: async () => {
 			return await Orpheus.getDownloadStatusByIds(ids)
 		},
-		staleTime: 0,
+		staleTime: 2000, // 2秒内复用缓存
 		gcTime: 0,
 		enabled: ids.length > 0,
 	})
 }
src/utils/player.ts (1)

26-30: 清理注释的调试日志。

代码中有多处被注释掉的 debug 日志(Lines 26-30 和 57-60)。请决定是删除这些日志还是取消注释。如果这些日志对调试有价值,建议取消注释;如果不再需要,应该删除以保持代码整洁。

Also applies to: 57-60

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 74767d5 and c40c117.

📒 Files selected for processing (4)
  • src/app/download.tsx (1 hunks)
  • src/features/downloads/DownloadTaskItem.tsx (7 hunks)
  • src/hooks/player/useBatchDownloadStatus.ts (1 hunks)
  • src/utils/player.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
src/features/downloads/DownloadTaskItem.tsx (2)
src/types/core/downloadManagerStore.ts (2)
  • DownloadTask (1-7)
  • DownloadState (9-12)
src/hooks/stores/useDownloadManagerStore.ts (2)
  • ProgressEvent (5-13)
  • eventListner (14-14)
src/hooks/player/useBatchDownloadStatus.ts (3)
src/hooks/queries/db/track.ts (2)
  • useTotalPlaybackDuration (66-74)
  • usePlayCountLeaderBoardPaginated (34-64)
src/hooks/stores/usePlayerStore.ts (6)
  • uniqueKey (826-835)
  • state (401-436)
  • checkPlayerReady (339-449)
  • track (689-717)
  • index (719-824)
  • state (830-834)
src/hooks/useCheckUpdate.tsx (2)
  • useCheckUpdate (6-32)
  • __DEV__ (8-31)
src/utils/player.ts (7)
src/types/core/media.ts (1)
  • Track (64-64)
src/lib/errors/thirdparty/bilibili.ts (1)
  • BilibiliApiError (25-50)
src/lib/errors/player.ts (1)
  • PlayerError (10-17)
src/lib/services/trackService.ts (2)
  • trackService (819-819)
  • formatTrack (72-108)
src/utils/log.ts (1)
  • flatErrorMessage (111-129)
src/lib/config/queryClient.ts (1)
  • queryClient (9-45)
src/hooks/queries/db/track.ts (1)
  • trackKeys (11-32)
src/app/download.tsx (4)
src/types/core/downloadManagerStore.ts (1)
  • DownloadTask (1-7)
src/hooks/player/useCurrentTrack.ts (1)
  • useCurrentTrack (7-63)
src/features/downloads/DownloadHeader.tsx (1)
  • DownloadHeader (12-39)
src/lib/config/queryClient.ts (1)
  • queryClient (9-45)
🔇 Additional comments (16)
src/app/download.tsx (5)

4-9: 导入和类型迁移正确

@roitium/expo-orpheus 导入 DownloadTask 类型和 Orpheus API,配合 React Query 的 useQuery 使用,整体迁移方向正确。


25-36: React Query 配置合理

staleTime: 0 确保每次访问页面都会重新获取最新数据,适合下载任务这种需要实时更新的场景。


40-45: Header 提取解决了之前的问题

header 提取为变量并在 isPendingisError 状态中复用,确保用户在任何状态下都能使用返回按钮。


80-86: 缓存失效逻辑已正确实现

onClearAll 现在在清除任务后调用 queryClient.invalidateQueries,解决了之前评审中指出的列表不刷新问题。


47-47: 该代码正确,无需修改

DownloadTask@roitium/expo-orpheus 导出,其类型定义包含 id 属性。虽然本地 src/types/core/downloadManagerStore.ts 中的 DownloadTask 使用 uniqueKey 而非 id,但这是两个不同的类型定义,分别服务于不同的功能模块。DownloadTaskItem.tsx 中对 task.idtask.state 的多处使用证实了外部包的 DownloadTask 包含这些属性。

src/features/downloads/DownloadTaskItem.tsx (8)

1-11: 导入结构清晰

@roitium/expo-orpheus 导入 DownloadState 枚举、Orpheus API 和 DownloadTask 类型,同时保留了事件监听器的导入用于进度更新。


20-28: useRecyclingState 用于 FlashList 优化

使用 @shopify/flash-listuseRecyclingState 是正确的做法,可以在列表项复用时正确重置状态。依赖数组使用 initTask.id 确保任务变化时状态会重置。


70-83: 状态文本逻辑清晰

getStatusText 使用 DownloadState 枚举进行分支判断,覆盖了所有预期状态并提供了默认处理。


137-148: 错误处理已添加

重试下载操作现在包含了 try-catch 和 toastAndLogError 调用,解决了之前评审中指出的问题。


151-151: 冗余三元表达式已移除

之前评审指出的 marginRight: task.state === DownloadState.FAILED ? 0 : 0 冗余代码已被移除,现在是简洁的 <View>{icon}</View>


154-164: 删除任务的错误处理完善

删除操作同样添加了 try-catch 处理,保持了与重试操作一致的错误处理模式。


169-169: useMemo 依赖数组需要审查

icons 的 useMemo 依赖包含 task.idtask.statetask.track,但内部还使用了 colors.error。当前依赖数组是正确的,但如果 task.track 是对象引用,可能会导致不必要的重新计算。

请确认 task.track 的引用稳定性。如果 task.track 每次渲染都是新对象,可能需要改用 task.track?.id 或类似的原始值作为依赖。


184-184: Fallback 文本处理得当

使用可选链和空值合并运算符 task.track?.title ?? '未知任务' 正确处理了 track 可能为空的情况。

src/utils/player.ts (3)

68-106: LGTM:播放历史上报重构合理。

函数重构为接受 uniqueKey 参数并通过 trackService 查询曲目详情的设计是合理的,符合新的 Orpheus 架构。错误处理完善,逻辑清晰。


185-195: LGTM:播放 URI 生成逻辑正确。

函数正确处理了 bilibili(包括多 P 视频)和本地音轨的 URI 生成,对未知来源返回 undefined 的处理也很合理。


197-245: LGTM:播放记录最终化逻辑完善。

此函数实现了完整的播放记录流程:

  1. 计算有效播放时长和完成状态(Lines 203-216),完成阈值设置合理(90% 或剩余 2 秒)
  2. 通过 trackService 记录播放数据(Lines 218-233)
  3. 失效排行榜查询缓存(Lines 235-237)
  4. 异步上报 B 站播放历史并捕获错误(Lines 239-241)

错误处理完善,逻辑清晰。

Comment thread src/utils/player.ts Outdated
@roitium roitium merged commit fe920a1 into dev Dec 13, 2025
3 of 4 checks passed
@roitium roitium deleted the native-player branch December 13, 2025 14:12
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/hooks/stores/useAppStore.ts (1)

76-107: clearBilibiliCookie 需要同步清除 Orpheus 侧的 cookie

setBilibiliCookieupdateBilibiliCookie 都会推送到 Orpheus,但 clearBilibiliCookie 目前只清了 Zustand 状态而未清 Orpheus;这会导致登出或切换账号时原生层残留旧 cookie。

另外,建议给 setBilibiliCookieupdateBilibiliCookie 中的 Orpheus 调用加上 try/catch 保护,防止原生异常导致 action 崩溃。这与其他 Orpheus 调用在整个项目中的错误处理模式一致。

 				clearBilibiliCookie: () => {
 					set((state) => {
 						state.bilibiliCookie = null
 					})
+					try {
+						Orpheus.setBilibiliCookie('')
+						logger.info('清除 cookie 到 orpheus')
+					} catch (e) {
+						logger.error('清除 orpheus cookie 失败', e)
+					}
 				},
src/app/_layout.tsx (1)

96-116: Cookie 的 falsy 检查建议补充空对象判断;且建议在初始化成功后设置 global.playerIsReady

现有代码只检查 if (!cookie),但 bilibiliCookie 可能是空对象 {}。此时条件为 false(空对象是 truthy),会继续调用 Orpheus.setBilibiliCookie(serializeCookieObject({})) 传入空字符串。建议参考 hasBilibiliCookie() 的做法,补充空对象检查。

另外,global.playerIsReady 在错误时设为 false,但成功时未设为 true,导致语义不清。建议在初始化成功后补上设置,以明确标记"播放器已完成初始化"的状态。

-								if (!cookie) {
+								if (!cookie || Object.keys(cookie).length === 0) {
 									logger.info('没有 bilibili cookie,跳过播放器初始化')
 									return
 								}
 								logger.debug('初始化 orpheus bilibili cookie')
 								Orpheus.setBilibiliCookie(serializeCookieObject(cookie))
+								global.playerIsReady = true
♻️ Duplicate comments (1)
src/utils/player.ts (1)

23-62: volume 值仍然硬编码为 0,响度归一化功能失效。

Lines 33-36 的 volume 对象中 measured_itarget_i 仍被硬编码为 0。这会导致 Orpheus 无法正确执行响度归一化。请确认这是预期行为还是需要从 track.bilibiliMetadata 获取真实值。

🧹 Nitpick comments (2)
src/utils/player.ts (1)

148-163: 转换失败时静默跳过可能导致用户困惑。

当多首曲目中部分转换失败时,仅记录日志而不通知用户。如果大量曲目失败,用户可能不知道为何只添加了部分曲目。

建议在批量添加结束后,若有失败曲目,给用户一个汇总提示:

 		if (orpheusTracks.length === 0) {
 			return
 		}
+		const failedCount = tracks.length - orpheusTracks.length
+		if (failedCount > 0) {
+			toast.warning(`${failedCount} 首曲目添加失败`)
+		}
 		if (playNext) {
src/features/player/components/PlayerFunctionalMenu.tsx (1)

123-128: 建议复用 trackId 变量以保持一致性。

Line 28 已定义 trackId = currentTrack?.uniqueKey,此处可复用以避免重复逻辑。

 				title={
-					downloadStatus?.[currentTrack?.uniqueKey ?? ''] ===
-					DownloadState.COMPLETED
+					trackId && downloadStatus?.[trackId] === DownloadState.COMPLETED
 						? '重新下载音频'
 						: '下载音频'
 				}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c40c117 and 38d137b.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (8)
  • app.config.ts (2 hunks)
  • package.json (3 hunks)
  • src/app/_layout.tsx (2 hunks)
  • src/features/player/components/PlayerFunctionalMenu.tsx (3 hunks)
  • src/hooks/player/useCurrentTrack.ts (1 hunks)
  • src/hooks/player/useIsCurrentTrack.ts (1 hunks)
  • src/hooks/stores/useAppStore.ts (3 hunks)
  • src/utils/player.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • app.config.ts
  • package.json
🧰 Additional context used
🧬 Code graph analysis (2)
src/features/player/components/PlayerFunctionalMenu.tsx (4)
src/hooks/player/useBatchDownloadStatus.ts (1)
  • useBatchDownloadStatus (4-14)
src/utils/player.ts (1)
  • getInternalPlayUri (250-250)
src/utils/error-handling.ts (1)
  • toastAndLogError (11-41)
src/types/core/downloadManagerStore.ts (1)
  • DownloadState (9-12)
src/app/_layout.tsx (1)
src/hooks/stores/useAppStore.ts (2)
  • useAppStore (58-239)
  • serializeCookieObject (40-46)
🔇 Additional comments (6)
src/utils/player.ts (3)

68-106: LGTM!

错误处理逻辑完善,包括设置检查、track 查询失败处理和 bilibili 上报失败的日志记录。


184-194: LGTM!

URI 生成逻辑清晰,正确处理了 bilibili 多 P 视频和本地文件两种情况。


196-244: LGTM!

播放完成指标计算逻辑合理(90% 或最后 2 秒),错误处理完善,使用 void 显式标记异步操作的 fire-and-forget 模式符合最佳实践。

src/features/player/components/PlayerFunctionalMenu.tsx (2)

28-31: 修复正确!

使用 trackId ? [trackId] : [] 避免了向 useBatchDownloadStatus 传递 [''] 的问题,且该 hook 内部有 enabled: ids.length > 0 的保护。


91-122: LGTM!

下载流程的错误处理完善,包括 URL 获取失败和下载失败两种情况的处理。使用 toastAndLogError 统一了错误日志记录。

src/hooks/player/useCurrentTrack.ts (1)

14-55: 该评论不准确。trackService.getTrackByUniqueKey() 使用 ResultAsync.fromPromise 包装 Promise,会将任何 Promise 拒绝转换为 Err 结果类型返回,而不会抛出未处理的异常。当等待该方法时,总是会获得 ResultAsync<Track, ServiceError | DatabaseError>,之后通过第 45 行的 internalTrack.isErr() 检查来处理错误情况。代码已经通过 Result 模式正确处理了所有可能的错误,无需添加额外的 try/catch。

Likely an incorrect or invalid review comment.

Comment on lines +5 to +30
export function useIsCurrentTrack(trackUniqueKey: string) {
const [isCurrent, setIsCurrent] = useState(false)
const lastRequestIdRef = useRef(0)

useEffect(() => {
let isMounted = true

const checkCurrentStatus = async () => {
const currentRequestId = ++lastRequestIdRef.current
let currentTrack = null

try {
currentTrack = await Orpheus.getCurrentTrack()
} catch (e) {
if (isMounted && currentRequestId === lastRequestIdRef.current) {
toastAndLogError('读取当前曲目信息失败', e, 'Hooks.useIsCurrentTrack')
setIsCurrent(false)
}
}
if (!isMounted || currentRequestId !== lastRequestIdRef.current) {
return
}

const isMatch = currentTrack?.id === trackUniqueKey
setIsCurrent(isMatch)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

参数名 trackUniqueKey 与比较字段 currentTrack.id 语义可能不一致,建议对齐命名/类型

这里用 currentTrack?.id === trackUniqueKey 判定,但参数叫 trackUniqueKey,容易让调用方传错(例如传 Track.uniqueKey 而 Orpheus 返回的是另一种 id)。建议至少改名为 trackId / orpheusTrackId,或加注释/类型别名明确两者一致。

-export function useIsCurrentTrack(trackUniqueKey: string) {
+export function useIsCurrentTrack(trackId: string) {
@@
-			const isMatch = currentTrack?.id === trackUniqueKey
+			const isMatch = currentTrack?.id === trackId
@@
-	}, [trackUniqueKey])
+	}, [trackId])
📝 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.

Suggested change
export function useIsCurrentTrack(trackUniqueKey: string) {
const [isCurrent, setIsCurrent] = useState(false)
const lastRequestIdRef = useRef(0)
useEffect(() => {
let isMounted = true
const checkCurrentStatus = async () => {
const currentRequestId = ++lastRequestIdRef.current
let currentTrack = null
try {
currentTrack = await Orpheus.getCurrentTrack()
} catch (e) {
if (isMounted && currentRequestId === lastRequestIdRef.current) {
toastAndLogError('读取当前曲目信息失败', e, 'Hooks.useIsCurrentTrack')
setIsCurrent(false)
}
}
if (!isMounted || currentRequestId !== lastRequestIdRef.current) {
return
}
const isMatch = currentTrack?.id === trackUniqueKey
setIsCurrent(isMatch)
}
export function useIsCurrentTrack(trackId: string) {
const [isCurrent, setIsCurrent] = useState(false)
const lastRequestIdRef = useRef(0)
useEffect(() => {
let isMounted = true
const checkCurrentStatus = async () => {
const currentRequestId = ++lastRequestIdRef.current
let currentTrack = null
try {
currentTrack = await Orpheus.getCurrentTrack()
} catch (e) {
if (isMounted && currentRequestId === lastRequestIdRef.current) {
toastAndLogError('读取当前曲目信息失败', e, 'Hooks.useIsCurrentTrack')
setIsCurrent(false)
}
}
if (!isMounted || currentRequestId !== lastRequestIdRef.current) {
return
}
const isMatch = currentTrack?.id === trackId
setIsCurrent(isMatch)
}
}, [trackId])
🤖 Prompt for AI Agents
In src/hooks/player/useIsCurrentTrack.ts around lines 5 to 30, the hook
parameter name trackUniqueKey may not match the field currentTrack.id returned
by Orpheus which risks caller confusion or wrong comparisons; rename the
parameter to something explicit (e.g., trackId or orpheusTrackId) or create a
clear type alias/comment that documents it, update the function signature and
all call sites accordingly, and/or change the comparison to use the correct
field (e.g., currentTrack?.uniqueKey === trackUniqueKey) so parameter name and
the compared track field are semantically aligned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: native player

1 participant