Skip to content

fix(player): centralize playback speed writes in a single controller - #3238

Draft
NihilDigit wants to merge 9 commits into
open-ani:mainfrom
NihilDigit:fix/3237-playback-speed-and-progress
Draft

fix(player): centralize playback speed writes in a single controller#3238
NihilDigit wants to merge 9 commits into
open-ani:mainfrom
NihilDigit:fix/3237-playback-speed-and-progress

Conversation

@NihilDigit

Copy link
Copy Markdown
Member

WIP

倍速原先有三个写入者:PlaybackSpeedExtension 跟随配置流写入,PlaybackSpeedControllerState 响应倍速 UI 写入,PlayerFastSkipState 在长按快进期间写入。三者都直接调用 PlaybackSpeed.set,互相看不到对方的状态,长按结束时只能依靠快照还原此前的倍速。

本 PR 新增 PlaybackSpeedController,由它统一写入倍速。

修复

长按快进松手后倍速失步。

PlayerFastSkipState 按下时快照 playbackSpeed.value,松手时写回。这个快照是否正确,取决于按下那一刻播放器上恰好是基础倍速。若 onStartonStop 不配对,写回的就是快进倍速本身;originalSpeed 初值为 0f,一旦在任何 onStart 之前触发 onStop,写回的是 0,mpv 会抛出异常(require(speed > 0f)),ExoPlayer 则表现为画面冻结。

改动后,松手只是丢弃临时倍速,播放器随即回到基础倍速,不再需要快照。

后台线程写入播放器,倍速同步中断。

PlaybackSpeedExtensionDispatchers.Default 上调用 PlaybackSpeed.set。ExoPlayer 要求在主线程访问,而 PlaybackSpeed.set 没有 @UiThread 标注,签名上看不出这条约束。异常抛在 collect 内部,倍速同步任务随之终止,此后所有倍速变更都不再生效。复现步骤:杀进程,重新进入播放页。

改动后只有 PlaybackSpeedController 调用 set,线程切换也只需在这一处处理。

结构

倍速拆成两个状态,生效值由二者推导:

播放器上生效的倍速恒为 临时 ?: 基础。生效值不单独保存,因此始终与这两个状态一致。

倍速控件读基础倍速,长按快进期间不跳动;剩余时间这类按真实播放速率计算的显示读生效倍速。

PlaybackSpeedControllerEpisodeViewModel 持有,与播放器同生命周期。临时倍速的存活范围必须大于单次 composition,否则重组或退出 composition 会把长按倍速留在播放器上。

PlaybackSpeedControllerState 相应改为适配层,只负责转发 UI 事件、把基础倍速映射为 Compose state,不再写入播放器。video-player 不依赖 app-data,它拿到的只有 StateFlow 和 lambda,类型上无法直接触及播放器。

EpisodeViewModel.setPlaybackSpeed 增加 persist 参数。拖动 Slider 期间不写 DataStore,松手和键盘调整时才写。

删除 PlaybackSpeedExtension

#3227 里引入这个扩展,是为了在切集后重新应用倍速,注释写的是“播放器在换片源后可能把速度重置回 1x”。这是一项防御性假设,三个主力后端均不重置:

后端 换 media 之后
ExoPlayer 倍速是 PlaybackParameters,属播放器级设置,stop()clearMediaItems() 都不影响
mpv speed 是全局属性,跨 loadfile 保持
AVKit AVPlayer.rate 兼任播放与暂停,换 item 时清零,但 resumeImpl 会用 playbackSpeedFeature.value 重新起播

对应测试 reapplies the speed after switching episode 一并删除。该测试手动调用 set(1f) 模拟重置,其断言的行为并不存在。

AVKit 的恢复依赖 playbackSpeedFeature.value 准确,这是统一写入口的另一项收益:绕开 PlaybackSpeedController 写播放器,会让 AVKit 用错误的值恢复播放。


基于 #3227,该 PR 合并前本 PR 的 diff 包含它的改动。

Closes #3237

NihilDigit and others added 9 commits July 28, 2026 10:16
Add a "remember playback speed" toggle, on by default, preserving the existing
global-sticky behaviour. With it off, speed is scoped to a single playback: it
survives episode switches inside the player page, resets on leaving, and
in-player adjustments are no longer written back to the config.

Speed previously had one source, the global config, and PlaybackSpeedExtension
re-applied that value on every onStart, so "for this playback only" could not be
expressed. No new holder is needed: PlayerExtensionManager is constructed once in
EpisodeFetchSelectPlayState and only onStart is per-session, so an extension
instance already lives exactly as long as the player page. The caller now supplies
the speed flow, and EpisodeViewModel holds a session-scoped override that falls
back to the config until the user adjusts it.

The toggle needs no upgrade migration. Users who had set a global speed keep their
stored playbackSpeed and start at the same speed as before; for users who never
adjusted it the value was already 1.

Digit keys 1/2/3 and their numpad equivalents jump to 1x/2x/3x, clamped to the
configured range. The long-press indicator now shows the speed actually in use,
and the Chinese description of that setting is corrected from 播放倍率 to 速度,
since it is an absolute speed rather than a multiplier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rewritten subject details page lost the caches entry point: desktop
had none at all, and on mobile it was buried inside the episode list
dialog. Add a "manage cache" button to the episodes SectionHeader,
matching the Figma spec (SectionHeader + Actions):

- Wide two/three-column: download icon + "Manage cache" text button
- Narrow two-column and compact: icon-only (the labelled button
  squeezes the section title into vertical wrapping with English
  strings), controlled by SubjectDetailsLayoutParams.showCacheButtonLabel

Verified via SubjectDetailsScreenshotTest at all four breakpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…el area (open-ani#3212)

fix(player): 滑动快进改为上滑取消, 不再使用角落取消区域 (open-ani#3211)

取消判定改为以起手位置为基准的垂直位移: 快进中向上滑动超过 144dp 即取消, 滑回阈值以内恢复. 起手落点不再直接决定是否取消, 修复右手拇指起手即误取消的问题, 同时简化手势模型.

进度条触摸拖动的取消判定同步改为同一套上滑阈值, 与屏幕横滑共用 isVerticalDragCancelled.

一并修复滑动快进松手后的 500ms 空指示器: 该指示器只由 SwipeSeekerState 驱动, GestureIndicatorState.state 全程为 null, 松手后淡出的 500ms 里 when 分支落到 null, 渲染出一个空 Surface. 改为在淡出期间保留最后一帧快照. 另: 快进过程中滑回原点现在显示 00:00, 不再沿用上一次的秒数.
…open-ani#3222)

- Add windows-11-arm to the release matrix, publishing
  ani-*-windows-aarch64.zip, and add the matching links to the release
  template
- Ship the WoA64 SQLite native on the runtime classpath: AndroidX's
  NativeLibraryLoader resolves natives/windows_arm64/sqliteJni.dll
  through the classloader, so the official jar no longer needs patching.
  This works uniformly for local gradlew run, tests, CI packaging and
  releases.
- Build that DLL from source in :ci-helper:sqlite-woa64 on Windows ARM64
  hosts. The module downloads the SQLite 3.50.1 amalgamation and the
  sqlite_bindings.cpp of androidx.sqlite 2.6.1, both pinned by sha256,
  and compiles them with MSVC using the same defines as the AndroidX
  build. No binary is checked in, and no manual step is needed:
  :app:desktop:run pulls the DLL in through app-data's runtimeOnly
  dependency. The compile task is only wired up on Windows ARM64 hosts,
  so other platforms are unaffected.
- Building it needs the Visual Studio "C++ ARM64 build tools" component,
  which the windows-11-arm runner already provides
- Drop the CI step that compiled with MSVC and patched the jar in place

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
取页统一改走 App 同一条 WebSessionManager 链路, 复用完全相同的 solver 链
(MacCMS 纯 HTTP 协议 → JCEF 浏览器 + ONNX 识别), 解出的 cookie 与 UA 同步到
HTTP 侧与播放页 WebView, 后续步骤沿用同一个会话.

MCP 是无人值守的评测工具, 没有 App 那样的手动兜底对话框, 因此只有两种结局:
能自动解则解完重试, 并在 details.autoSolvedCaptcha 记录解掉的类型; 解不掉则
立即终止该数据源的整个流程 (不再换搜索词硬试, 也不再对站点发请求), 避免把
"被挡" 误报成 "selector 写错了".

顺带对齐三处 App 行为:
- test_subject_episode_source 的 selector 源改为共用同一个会话管理器
  (原先固定用 UnsupportedCaptchaBrowserFactory, 等于永远解不了);
- 视频解析的 matcher 注入验证码 cookie, 播放页沿用刚解掉的会话;
- 验证码浏览器与视频解析共用同一个 CefApp 与 cache 目录.

改动全部限于 tools 模块与其文档, 未改 App 源码.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(player): centralize playback speed writes in a single controller

WIP. 新增 PlaybackSpeedController 作为倍速的唯一写入者, 把倍速拆成基础倍速与
临时倍速两层, 生效值为 `临时 ?: 基础`。

修复长按快进松手后倍速失步 (原先依赖 originalSpeed 快照), 以及
PlaybackSpeedExtension 在后台线程调用 PlaybackSpeed.set 抛 wrong-thread
异常导致倍速同步任务终止。

删除 PlaybackSpeedExtension 及其测试: ExoPlayer / mpv / AVKit 换 media 都不会
重置倍速, 该扩展的重新应用逻辑没有对应的实际行为。

尚未编译与测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

倍速行为异常

3 participants