Conversation
- MediaSmall: Card(onClick) + focusGroup() + border焦点边框 - FavouriteScreen: 触摸长按(LaunchedEffect) + D-pad长按(Handler) - HomeScreen: Tab改用Surface + LazyVerticalGrid添加focusGroup() - VideoPlayerScreen: D-pad控制改用KeyUp,左右键隐藏时先展示UI - VideoPlayerControl: 添加modifier和sliderFocusRequester参数 - Slider: 添加focusRequester参数
- Card border颜色统一为#BCC0C8(深色浅色一致) - Week页面TopAppBar标题区域改为圆角矩形焦点背景 - Home页面站源文本移除clickable,不再可聚焦 - 详情页标签改为非交互Box,不再可聚焦
- SourceHolder.isSourceChanged改为计数器,避免布尔标志竞争条件 - 两个页面切换源时递增计数器,所有页面同时刷新 - Home页面站源改为Surface+SourceSwitchDialog,与Week页面统一 - Home页面站源添加圆角矩形焦点背景 - 移除Week页面源切换的确认/取消按钮,选中即切换
- 移除Home站源自定义焦点颜色中的冗余代码 - 统一使用surfaceVariant焦点背景色
搜索页: - 重构搜索栏为双模式布局(编辑/非编辑),适配TV遥控器D-pad导航 - 编辑模式支持方向键防冲突、自动聚焦输入框 - 非编辑模式显示当前查询文本,点击/按确认键进入编辑 - 搜索结果自动聚焦第一项,移除isAndroidTV条件判断 - 添加BackHandler处理返回键优先级(菜单>编辑>返回) 验证码: - CaptchaWebViewActivity 添加 windowSoftInputMode=adjustResize - 自动轮询点击"我已了解/知道了/确定"公告按钮 - 自动聚焦验证码输入框,提升操作效率 - 添加BackHandler焦点流转: WebView→确认按钮→退出 - 按钮添加焦点边框高亮,支持TV导航 焦点导航统一: - 所有IconButton添加onFocusChanged高亮(primary色容器背景) - MediaSmall焦点边框改为primary主题色 - 移除各处isAndroidTV条件判断,统一支持所有设备 - SourceHolder.isSourceChanged改为MutableStateFlow - VideoPlayerScreen移除isAndroidTV守卫,统一焦点行为 - build.gradle.kts: ui.tooling改为implementation作用域
…t, grid tab focus, NavBar text color
…player controls, navigation focus
…ive, slider dpad support
- Add focus utility toolkit: handleDPadKeyEvents, rememberIsFocused - Detail page: replace Box with Column layout, route vertical focus between episodes/controls/related via FocusRequester - Settings page: route ColorBall vertical focus from outer rows, focus to first color item, use onSurface for border contrast - Add VS Code build task for install & launch Change: detail-settings-focus-fixes
… detail page - Replace lastPlayedFocusRef (v1-v4) with function-level FocusRequester on last played item + LaunchedEffect(Unit) for initial focus only - EpisodeListControl.onUp and FavouriteIcon.onDown use moveFocus for natural D-pad navigation without external FocusRequester refs - Archive detail-settings-focus-fixes change - Sync delta spec to openspec/specs/tv-focus-traversal/
…hanges Changes were too trivial for formal openspec workflow — direct implementation committed separately.
…, compact seek overlay - Slider: fix onFocusChanged modifier order (before focusable) so thumb animates on focus (15→20dp + 3dp white border) - Slider: onKeyEvent uses onClick instead of onValueChange+onValueChangeFinished to avoid isSeeking toggle - Slider: D-pad seek step unified to 10s; remove unused focusedColor param - focusProperties explicit routing: back↔slider↔playPause via FocusRequester (bypasses outer fillMaxSize Box) - Hidden-UI LEFT/RIGHT: 10s seek + compact overlay (isSeeking hides header/playback) + focus slider for continuous seek - hideAfterMs: 1.5s short timer during seek, 6s default; onSeeked no longer resets isSeeking (no full UI flash) - onTimedSeek added to VideoPlayerState interface+impl; UP/DOWN/OK show full UI with deferred focus - hideControlUi no longer resets isSeeking; showControlUi resets isSeeking=false + hideAfterMs=6s - Fullscreen button hidden on TV (FEATURE_LEANBACK) - Speed/resize side sheets: auto-focus first item + BackHandler + selected item bold - Auto-hide timer resets on D-pad via onPreviewKeyEvent + onUserInteraction - TimelineIndicator hidden with control UI (add isControlUiVisible check) - Remove unused controlFocusRequester, wildcard imports
- Fix crash when pressing UP/DOWN during compact seek mode (Slider consumed keys unconditionally, now only during isSeeking) - Fix side sheet overlapping: outer key handler now skips when episode/speed/resize sheets are open - Fix failure page: retry button now receives initial focus via FocusRequester - Add player gradient overlay (top/bottom 55% black → 25%/75% transparent) - Gradient hidden during compact seek mode - New spec: player-focus-dpad-safety - Updated spec: player-focus-navigation (side-sheet guard + compact seek exit) - Updated spec: player-gradient-overlay
- Root cause: .clip(CircleShape) anti-aliased edge pixels blending with gradient overlay - Second cause: .border(0.dp) still inserts BorderStroke in Skia rendering path - Fix 1: .background(color, CircleShape) replaces .clip().background() — rasterizes directly as circle - Fix 2: .then(if active border else Modifier) instead of .border(0.dp) - Also: reduced active border from 3dp to 2dp - Spec archived at openspec/changes/archive/2026-07-12-player-slider-thumb-render
- Unify rememberInteractionFocus → rememberIsFocused across all screens - Remove rememberInteractionFocus from FocusHighlight.kt - Player Back key intercept via root onKeyEvent (tablet fix) - D-pad OK toggle: pause+showUI / play only (no UI) - Remove showControlUi() on player initial load - Delete mode: FavouriteScreen + DownloadDetailScreen - App exit confirmation dialog on MainScreen - Download episode picker auto-focus - DownloadDetail screen: anime detail nav button - DownloadDetail: expose sourceMode in navigation route
- Add statusBarsPadding to prevent overlap with system status bar on tablets - Use onGloballyPositioned to measure InputField height and match non-editing Row - Add missing BackHandler for non-editing mode (back button works now) - Fix OK/DirectionCenter key in editing mode to trigger search - Match Row edge padding (12dp) to InputField internal padding for zero visual jump - Remove unused imports (SearchBar, rememberSaveable, focusable, AnimeTheme)
- Add pureBackground preference with toggle in Appearance settings - Override background and surface colors: white (#FFFFFF) for light, #121212 for dark - Skip background blending on HomeScreen and AnimeDetailScreen when enabled - Compatible with dynamic color (Material You) — only background/surface affected
- MediaSmall: use Card(onClick) with zero elevation + fixed-width border (3dp, color-only change) to eliminate elevation animation and relayout on focus change. Release build jank: 7.41% (vs official 7.65%) - WeekItem: add focus border + zero elevation to ElevatedCard - build.gradle.kts: fallback to debug signing when release keystore env vars are not set
Prevent timeline seeking from activating when drag starts within 40dp of screen edges, avoiding conflict with system back gesture navigation.
…below title - Increase gradient overlay opacity from 0.55 to 0.75 and extend gradient coverage (0.25→0.35 top, 0.75→0.65 bottom) for better title readability - Display video resolution (e.g. 1920×1080) and bitrate (e.g. 5.2Mbps) as subtitle line below the title in the player header
Replace composition-phase Modifier.border + rememberIsFocused with
draw-phase focusBorder (drawWithContent + onFocusChanged via
Modifier.composed). Focus state is read only in the draw lambda,
so focus changes trigger draw invalidation only — no recomposition,
no relayout.
- FocusBorder.kt: new draw-phase modifier using composed{}
- MediaSmall.kt: use focusBorder on Card modifier
- WeekScreen.kt: use focusBorder on ElevatedCard modifier
- AnimeDetailScreen.kt: simplify isActive to isFocused only
- 线路/选集组合:侧栏支持线路 tab + 集数选择,线路数据随 PlayerParameters 传递 - 侧栏交互:tab/集数分层(左右切线路保同 index、UP 聚焦当前线路 tab、播放指示跨线路保留) - 详情页线路选择记忆修复(Room 重发不覆盖用户所选线路) - 切换剧集/线路失败保留播放器 + 错误浮层可重试;取消上一条加载防竞态 - 播放器:胶囊按钮样式、setLoading 清除播放错误、tab 播放指示、错误/加载圈正确切换
- 移除 hasShownOnce:pendingFocusIndex 初始化为 focusIndex,首开/左右切统一由 pending 驱动 - channelFocusRequesters List 改为单个 channelFocusRequester(UP 恒聚焦当前线路 tab) - 合并侧栏按钮共用样式、错误浮层
…ll border - Add shared focus helpers (focusedIconButtonColors, focusedTextButtonColors, focusedOutlinedButtonColors, FocusedDropdownMenuItem) replacing ~30 duplicated inline color blocks; convert remaining raw focus state boilerplate to rememberIsFocused - Extract SourceModeMenu (SearchScreen) and DeleteOverlay (Favourite/ DownloadDetail) shared composables - Restore MediaSmall onLongClick via combinedClickable, removing the hand-rolled pointer long-press hack in FavouriteScreen; keep focusBorder outside the clickable layer so the D-pad focus border renders correctly - Trim unused onLeft/onRight/onEnter params from DpadKeyHandler and dead isTv from VideoPlayerControl - Drop redundant onRefresh/onSourceChanged plumbing (source switch already refreshes via isSourceChanged StateFlow) - Revert ui-tooling to debugImplementation so preview tooling is not bundled into release builds
之前的 .gitignore 把整个 .github 目录一并忽略,并连带删除了工作流的跟踪, 这里恢复 android_ci.yml / release.yml。 仅本地使用的笔记(docs/、AGENTS.md)不再跟踪,改由 .git/info/exclude 管理, 避免混进 PR 给评审增加噪音。 顺带回退 app/build.gradle.kts 中「release 签名缺失时回退到 debug 签名」的 改动,构建脚本保持与上游一致。
两个 workflow 的 "Set up Android SDK" 步骤挂在构建之前,后续步骤全被 skip:
job 11~18 秒就结束,日志里没有 Gradle 输出。原因是
android-actions/setup-android 会比对镜像里的 cmdline-tools 版本,不一致就自行
下载它固定绑定的那一版,而 Google 已把这些 zip 从仓库下架:
Downloading commandline tools from .../commandlinetools-*-<rev>_latest.zip
HTTPError: Unexpected HTTP response: 404
上游 issue android-actions/setup-android#536 自 8 月起开着且没有修复版,
最新版 v4 同样中招。GitHub 把 Node20 的 action 强制跑在 Node24 上后又放大了
这个问题(见 check run 注解)。
判定为环境问题而非本仓库代码问题的依据:同一份 workflow、同一行 @V3,
在 main 上 2026-05-29 是成功的,2026-09 全挂。
修法:直接删掉该步骤。ubuntu-latest 镜像已经预装本项目需要的 SDK 并且
已接受许可,这一步本来也是多余的:
ANDROID_HOME=/usr/local/lib/android/sdk
cmdline-tools 12.0 / platform-tools 37.0.1
platforms;android-36(本项目 compileSdk = 36)
build-tools 37.0.0 / 36.0.0 / 36.1.0 / 35.x / 34.0.0
顺带 actions/setup-java@v4 -> @v5(v4 已被官方标记弃用,见同一处注解)。
未改动任何构建逻辑。
wdw89
force-pushed
the
pr/sources-reliability
branch
from
September 16, 2026 14:46
9d95c63 to
c6ffbaa
Compare
详情页把焦点停在剧集控制行(线路/正序/更多)按遥控器 ↓ 时,抛 IllegalStateException: FocusRequester is not initialized。 firstRelatedFocusRequester 只在 AnimeRelated 的 LazyRow index == 0 的卡片上挂 Modifier.focusRequester()。当该卡片被 LazyRow 回收(或在相关推荐为空的页面 从未挂上)时,修饰符从焦点树上摘除,requester 变为未初始化,此时 requestFocus() 直接抛异常。 修复: - EpisodeListControl 的 onDownFocusRequest 改为可空。DpadKeyHandler 的语义 本就是「传 null 即放行该方向键」,所以相关推荐为空时不再拦截 ↓。 - 相关推荐为空时整块不渲染,不再留下一个空的标题区块。 - 相关推荐非空但首项已被回收时,requestFocus() 加 runCatching 守卫, 失败则退回 focusManager.moveFocus(Down) 走默认焦点搜索。 真机验证(Sony BRAVIA 4K VH21 / Android 12 / 1.3.5): 详情页 → 控制行 → 相关列表右移 14 次使首项回收 → 回控制行 → 按 ↓ - 修复前:必现 FATAL EXCEPTION,进程死亡并跳转崩溃页;产出的崩溃日志与 用户上报的那份 SHA-256 逐字节相同(BA5AE586…65C79)。 - 修复后:无异常,焦点正常下移到相关推荐卡片。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
播放器 (video-player) - m3u8 改用 HlsMediaSource:此前无论何种地址都用 ProgressiveMediaSource,而 media3-extractor 不含 HLS 提取器,导致所有 m3u8 源报 UnrecognizedInputFormatException - 修复重试崩溃:getVideoFromRemote 使用 _videoState.value.data!!,而 retry() 会先把 状态置为 Loading(data 为 null)导致 NPE;改为用初始化时构建的骨架兜底重建 - 切集/切线路时同步更新 headers,剧集索引取值改为 getOrNull 数据源 - WebViewUtil:匹配时跳过入口页面自身。播放器页地址含 m3u8 字样时(如 .../m3u8/?url=...)会在真正媒体请求之前误命中,把 HTML 当视频地址;并在 shouldInterceptRequest 中上报 XHR 请求(Hls.js 拉取列表不触发 onLoadResource) - Agedm:修正正则,覆盖无扩展名地址(/video/tos/,24 分钟正片)且不误命中播放器页 - Gugufan:移除为绕过 HLS bug 而写的 emptyMap() 特例,统一带 Referer/UA - Silisili:POST 复用共享 OkHttpClient 并将超时对齐 30s(原先每次新建、默认 10s) - Xifan:去掉导致沃云盘 CDN 返回 400 的 Referer(该 CDN 拒绝非同站 Referer) - Cycanime:播放接口补上 /v2 前缀(缺前缀服务端返回 500),区分 401 与无资源 - Girigiri:搜索改用免验证码的 maccmsSuggest 接口,随之移除已废弃的 CaptchaCookieManager.captchaUrl 流程(captchaUrl 由 pendingWebAuth 取代) 次元城登录 - 播放需 Bearer token,且站点下发 token 自带 "Bearer " 前缀,需规范化避免重复 - 复用验证码 WebView 通道实现网页登录:桌面 UA(移动 UA 会被站点拦截)、 轮询 Web Storage 中的会话、登录后自动重试当前集 - TV 适配:自实现 D-pad 焦点移动(Chromium 不移动 DOM 焦点)、readonly 抑制 自动弹键盘、仅在按 OK 时弹键盘、返回键仅收键盘
播放地址解析
- 原实现依赖第三方代理 API(consumet-leox-api.vercel.app),该域名已被 DNS 污染
(解析到 Meta 的 IP 段,连接被重置),官方 api.consumet.org 也已改为 451 仅供自建,
因此改用站点自身的解析链路,全程无需代理:
剧集页 → /player/?source=embed&url=... → megavid iframe → <iframe>/source → m3u8
/player/ 必须带站点 Referer,否则 302 回首页;备用线路 source=blogger 提供
googlevideo 直链(mp4)兜底。
- 移除随之失效的 consumet DTO(ResponseData/Headers/Source)与多余的 HttpClient 依赖。
剧集顺序
- 站点按"最新在前"排列(Episode 20 → Episode 1),与其他数据源相反,解析后反转为升序,
使详情页默认顺序一致;需要"最新在前"可用已有的倒序按钮。
- 续播定位靠 URL 匹配(GetAnimeDetailUseCase)而非下标,反转不影响续播。
测试
- 新增离线单测(真实抓取片段 + 3 个用例):覆盖顺序、href 提取、空列表;
为此把纯解析逻辑提为顶层 parseEpisodes(object 的 baseUrl 依赖 Android Context,
JVM 单测无法初始化,不能挂在 object 上)。
- 顺带修正 href 读取:选择器已定位到 a,用 it.attr("href") 取代
依赖 jsoup "select 包含自身"行为的 it.select("a").attr("href")。
- 旧测试内联复制的解析逻辑与失效的旧结构 fixture(home/player/search/week)一并清理。
数据源注册
- 重新注册 Gogoanime(上游本就启用),移除已确认失效的 Anfuns 残留注释。
数据源 - Anfuns:上游唯一被注释掉的源,站点已停放(anfuns.org → example.com, www.anfuns.org 跳转垃圾站,anfuns.cc/.net 不可达),连 SourceMode 残留注释一并清除。 - Mxdm / Ntdm / Nyafun / Yhdm:站点已不可用或解析器长期失效。 测试 - CycanimeSourceTest 及 html fixture:CycanimeSource 已从 HTML 抓取改为官方 JSON API, 该测试仍在用内联复制的 HTML 解析逻辑跑旧结构 fixture、从不调用被测类, 既无法回归也必然失败(依赖已移除的 Elements.removeFirst() 与失效路径),占 375KB。 - NyafunSourceTest / YhdmSourceTest 及其 fixture:随对应数据源一并移除。 其它 - 数据库测试改用仍存在的数据源(Silisili)替代已删除的 Yhdm。 - 详情页注释去掉对具体站的指名。
从本地调试用的大量临时探测脚本中筛选出两个有长期回归价值的测试:
- AllSourcesChannelProbeTest:遍历全部数据源的每条线路,走「搜索 → 详情 →
getVideoData → 真实 ExoPlayer 播放」完整链路并输出汇总。本轮修复中确实
捕获到真实回归(改正则时误伤 Agedm 线路1 的无扩展名地址),因此保留。
- CycaniLoginFlowTest:次元城登录逻辑(触发条件、token 提取与 Bearer 规范化、
按数据源隔离),无需真实账号即可验证。
其余一次性探测脚本(用于逆向选择器/接口,解析逻辑内联复制、随站点改版失效)
已就地删除,不纳入版本库;上游 androidTest 同样不含此类脚本。
注意:两者都依赖真实站点网络,不在 CI 范围内(CI 仅执行 assembleDebug/Release),
需要设备/模拟器手动执行:
./gradlew :app:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=<FQCN>
目录结构对齐作者既有约定:
单元测试 test/java/<pkg>/parse/<source>/<Source>Test.kt
仪器测试 androidTest 复用同一包名(作者对 dandanplay 即如此,
dandanplay/DandanplayClientTest 与 DandanplayDanmakuProviderTest)
- sources/ 包(自建,作者无此包)取消,拆入 parse/ 下:
AllSourcesChannelProbeTest → parse/AllSourcesPlaybackTest
CycaniLoginFlowTest → parse/cycanime/CycaniLoginFlowTest
"Probe" 是调试期命名,作为常驻回归用例改用 Playback;logcat tag 同步
由 SRC-PROBE 改为 SRC-PLAY。
- bearer 前缀规范化用例下沉为 JVM 单测
(test/.../parse/cycanime/CycanimeSourceTest.kt,沿用作者原路径):
该用例只依赖纯函数 toBearerValue,原先混在仪器测试里导致必须插设备才能跑。
需要 WebView/Context/网络的三个用例留在 androidTest。
JVM 单测 15 个、仪器测试 3 个均已通过。
注:androidTest 的 sources/ 包为本地调试期引入,上游从无此包。
文件大小 (video-player) - probeMediaSize:远程用 HEAD 取 Content-Length(交给 DefaultHttpDataSource 从 Content-Length/Content-Range 解析,不自己读响应头),本地直接读文件长度, 在 IO 线程探测,不阻塞主线程。 - HLS 显式返回 null:播放列表只是索引,其自身字节数不代表媒体大小。 码率 - 优先用数据源自带的声明码率(Format.bitrate),即 HLS master 的 BANDWIDTH 或 MP4 的 btrt/esds。 - HLS 无声明码率时(Girigiri 这类只有 media playlist 的源)改由分片级统计估算: SegmentBitrateMeter 累计已下完分片的字节数,除以这些分片覆盖的媒体时长, 按 30 秒媒体时长的滑动窗口刷新;UI 加 ≈ 前缀标明是估算值。 窗口按媒体时长而非分片个数,是因为分片时长不固定(TARGETDURATION 只是上限)。 - 分片事件过滤按 SEGMENT_TRACK_TYPES 判定:muxed .ts 分片上报的是 TRACK_TYPE_DEFAULT 而非 TRACK_TYPE_VIDEO,只认 VIDEO 会把分片全部漏掉。 - 冷启动首次统计要等首片下完(约 6~7 秒),期间显示 "Bitrate ≈--" 占位; 该占位只在源本身是 HLS 时出现,非 HLS 无声明码率的视频不会停在占位符上。 移除旧的下载字节回退 - 该回退用 TransferListener 统计下载字节除以已缓冲时长估算码率,但拖动进度条 会重置缓冲,锚点与字节数不同步,估算值偏低甚至短暂消失。 - 且它在 HLS 上恒为 null(首片落地前 buffered == anchor,分母不足 10 秒), 而声明码率缺失的场景本就集中在 HLS,回退链等于从未生效。 连带删除 DownloadBytesCounter、averageBitrateBps 及相关重置逻辑。 实测 - Cycanime 死神 ep02(声明码率): 1920×1080 · Bitrate 17.1Mbps · 788 MB - Girigiri 死神 ep02(无声明码率的 HLS): 1920×1080 · Bitrate ≈2.9Mbps (该片实测真实均值 2344 kbps,2.9 Mbps 落在单片 VBR 抖动区间内) - 住在拔作岛 ep02(MP4): 1920×1080 · Bitrate 1.0Mbps · 215 MB Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
副标题栏 (app) - HLS 一律显示分片级实测码率(带 ≈ 前缀),不再读清单里的 BANDWIDTH。 Agedm 线路5 声明 0.8Mbps、实测平均 1.354Mbps,声明值只有真值的 59%: RFC 8216 里 BANDWIDTH 只是峰值上界估计,实测平均值反超它也是常态, 拿它当真实码率会误导;「先判断声明值准不准」做不到,判断需要真值, 而真值正是要显示的数。 - 文件大小分三种:HLS 没有单一文件大小、HEAD 探测出了结论却拿不到长度的, 都显示「大小未知」;探测还没结束的先什么都不写,免得先闪「未知」 再跳出真实大小。 - 非 HLS 保持声明码率 + 真实文件大小不变。 状态 (video-player) - 新增 isMediaSizeProbed 区分「还没测」与「测完拿不到」:原来两者都是 mediaSizeBytes == null,UI 分不出该不该写「大小未知」。 - setMediaSize(null) 拆成 resetMediaSizeProbe()(换集先清空,免得沿用上一集 的结论)与 setMediaSize(bytes)(探测有结论,置 isMediaSizeProbed)。 实测 (Switch, Agedm 详情 20260196) - 线路5 HLS 第01集:1920×1080 · Bitrate ≈1.5Mbps · 大小未知 30 秒后刷新为 ≈1.6Mbps,时长 24:17 与真值 1457.62s 吻合 - 线路1 MP4:第01集 1920×1080 · Bitrate 4.0Mbps · 276 MB, 第02集 415 MB(换集后大小随之更新,不会沿用上一集) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
副标题栏 (app) - 只读 Format.bitrate 会把峰值当成平均值显示:media3 的 Format 构造函数按 bitrate = peakBitrate != NO_VALUE ? peakBitrate : averageBitrate 填充, 而 BoxParser 处理 MP4 btrt 时只调 setAverageBitrate/setPeakBitrate。 btrt 写 max=4.0Mbps / avg=1.5Mbps 的片子于是在 UI 上显示成「4.0Mbps」, 看着像站点虚标,其实是峰值被当成了平均。 - 改为读 averageBitrate 显示平均(averageBitrate 缺失才回落 bitrate), peakBitrate 单列「峰值」。 - 峰值与平均值相同、或峰值缺失/为 0 时不显示峰值项:max==avg 的文件是 muxer 把真实平均值同时写进了 max,显示两遍同一个数只是噪音。 实测 (Switch, Agedm) - 20260196 ep01 线路1:1920×1080 · Bitrate 1.5Mbps · 峰值 4.0Mbps · 276 MB 容器 btrt max=4,000,000 avg=1,471,567,stsz 累计 1,471,697 佐证平均口径 - 20260181 ep01 线路1:1920×1080 · Bitrate 1.6Mbps · 353 MB(无峰值) 容器 btrt max=avg=1,639,555,stsz 累计 1,639,337,峰值与平均值同值被去重 - 线路5 HLS:Bitrate ≈1.5Mbps · 大小未知,不受本次改动影响 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- 改名:CaptchaCookieManager → SourceAuthManager(只保留登录 token 存取, 删除已无触发者的 Cookie 读写链);CaptchaWebViewActivity → WebAuthActivity, 只保留登录模式;pref 文件 captcha_cookies → source_auth - 删除死代码:SearchViewModel/SearchScreen 的验证码对话框与状态(登录入口 现在只在播放器)、GirigiriSource 空转的 WebViewUtil 与两处未使用 LOG_TAG - 修复搜索分页:次元城透传 page;两个 maccms suggest 接口源在 page>1 返回空 (此前 PagingSource 永远拿不到空列表,会无限翻页并追加重复条目) - 修复 SilisiliSource 对空响应 substring 导致的 StringIndexOutOfBoundsException - WebAuthActivity 补 webView.destroy(),此前每次登录泄漏一个 Activity - encodeForUrl 四处重复实现合并为 DownloadManager 中的一个
其中 9 处是早期重构后失效的(如 AnimeDetailScreen 的 focusable/hoverable/offset、 HomeScreen 的 animateColorAsState/clickable),其余为新增代码带入。 全仓未使用 import 复查为 0。
详情接口本身不含相关推荐,站点前端是另发一条请求取的(从 SPA 分包逆向):
GET /api/videos/{id}/recommendations?limit=12,返回结构与首页/搜索一致,
直接复用 parseVideoList;并沿用官网的自身过滤。
顺带修掉注释里的两处笔误(JSON A{page}I / 搜索示例 page 参数)。
针对 ee0a788(v1.3.5)..HEAD 的评审结论逐条整改。 文档 (AGENTS.md 与代码不符的地方) - 源数量写的是「11 个 AnimeSource 实现」,实际只有 7 个(7 个 *Source.kt 加 接口本身,SourceMode 也是 7 项),改对并列出名字,同时说明 SourceHolder.getSource 是唯一的 mode→实现映射,别再另抄一份列表。 - 把整个 parse/ 层说成「Jsoup scrapers」不准确:次元城走 JSON API(org.json), 咕咕番在拿不到直链时回退到 WebView 嗅探(parse/util/WebViewUtil.kt)。 - 「测试依赖本地 HTML fixture」已过时:只剩 gogoanime/html/detail.html 一个, 其余用例走真实网络。 - 补一节「Source auth」——SourceAuthManager 与 WebAuthActivity 这条链路此前 在架构说明里完全没提。 玩家侧栏 - 倍速/显示比例两处 SideSheet 是同一段代码抄了两遍(SideSheet(0.2f) → 等分布局 → 首项 focusRequester → 自动取焦),合并为泛型 SelectableSideSheet, 差异只剩 itemModifier(比例的按钮要固定 42dp,倍速要按内容自适应以免 「1.25X」被截断)。 - 两项列表由 Pair<String, V> 改为 SideSheetOption<V>(label, value),调用点 不再靠 .first/.second 区分语义。 焦点配色 (FocusHighlight.kt) - focusedIconButtonColors / focusedTextButtonColors / focusedOutlinedButtonColors 以及 FocusedDropdownMenuItem 各自重写了一遍「聚焦=primary 底 + onPrimary 字」, 收敛到一个私有 focusHighlightColors(),以后调色只改一处。 鉴权请求交接 - SourceAuthManager.pendingWebAuth 是公开可变全局变量,解析器在后台线程写、 ViewModel 在主线程「先判空再置空」,存在同一请求被消费两次的竞态。 改为 private,只经 @synchronized 的 requestWebAuth / consumePendingWebAuth 进出, 取走即清空变成原子操作。 - 需要登录的站点在多个源之间共享,这个交接通道本该只暴露语义化动作, 不该把内部状态直接摊开。 其他 - VideoPlayerScreen 的 `LocalActivity.current ?: LocalActivity.current as Activity` 是自引用兜底:右侧表达式与左侧完全相同,null 时 `null as Activity` 只会抛 NPE,永远兜不住。改为 requireNotNull 并给出可读原因。 - 全源播放烟雾测试里硬编码的 7 项源列表改为 SourceMode.entries,与枚举保持同步。 未整改(评审条目本身不成立或超出范围) - 各源解析播放页地址的正则看起来重复,但锚点各不相同(有的盯 "url_next"、 有的取整个 player_aaaa 对象、有的用 indexOf),是按各站实际返回手调的, 强行合并会让解析静默失效。 - video-player 模块的 `context as Activity` 在 ee0a788 中就存在,属上游代码,不是本次引入。 验证 - app 模块 assembleDebug、assembleDebugAndroidTest、test 均通过 - Switch OLED (API 35) 安装启动无崩溃 - danmaku:test 的 2 处失败为既有问题(Dispatchers.Main 未设置),与本次改动无关
针对 785e467..HEAD 的第二份评审逐条核对后整改。其中 4 项属实、4 项经查为误报、 2 项风险大于收益暂不动。 崩溃(评审 blocker,已确认为真) - SourceMode 在本区间从 11 项删到 7 项(移除了 Mxdm / Ntdm / Nyafun / Yhdm), 而收藏、历史、下载三张表存的是枚举名。三处读取点用的是裸 valueOf: FavouriteEntity.toFavourite / HistoryWithEpisodes.toHistory / DownloadWithDownloadDetails.toDownload。 - 关键点:异常是从 RoomRepositoryImpl 的 Flow 里冒出来的,不是跳过一行—— 它会把整个列表一起打挂,老用户打开这三个页面直接崩。 - 改为 SourceMode.fromName()(按名字查、查不到返回 null),三个 mapper 返回可空, 三个调用点换 mapNotNull。 - 新增 RemovedSourceRowTest:覆盖三张表,先断言原始行数是 2、再断言过滤后是 1, 避免插入失败也能让测试假绿。 线程安全(video-player) - SegmentBitrateMeter 的 samples 是普通 ArrayDeque、isEnabled 是普通 var, 却同时被播放线程(onLoadCompleted)和主线程(reset,换集/换线路/切换统计来源) 访问。改为统一用一把锁护住;过滤条件留在锁外,因为它们只读事件自带的不可变数据。 WebView 释放(webauth) - destroy() 从 DisposableEffect.onDispose 挪到 AndroidView(onRelease = ...)。 销毁必须发生在 View 从父容器摘除之后,否则渲染进程/Adapter 泄漏并打 Chromium 警告。 比之前少几行,顺带删掉不再需要的 DisposableEffect import。 日志 - ktor Logging 的 level 改为按 BuildConfig.DEBUG 走 HEADERS / NONE。 注意:评审说的"release 会把 Bearer token 打进 logcat"并不成立——Logger 走的是 本仓的 log 扩展(util/Util.kt),它本身已被 BuildConfig.DEBUG 包住且用 Log.d/Log.w。 这次改动的实际收益是:ktor 会先把请求头拼成字符串再交给 logger,release 下等于 白造一份含令牌的字符串,关掉连拼接都省了。 注释订正 - VideoPlayerSate 里"实测码率只给没有声明码率的 HLS 兜底"与代码不符: 该值对所有 HLS 生效,显示侧(控制栏)根本不读清单里的声明 BANDWIDTH。 经核对为误报,未改(附理由,避免以后重复排查) - ktor 令牌泄漏:见上,Logger 已被 DEBUG 拦住。 - "token 轮询不会停":return@LaunchedEffect 是带标签的非局部返回,会终止整个协程; repeat 是 inline,循环不会继续。detectedToken 未清空是洁癖问题,且 finish() 后 手动按钮已不可达、重复回调也幂等。 - "WebAuthActivity 注释自相矛盾":原文已写明 inputmode="none" 在本 WebView 无效、 readOnly 才是真正手段,注释是准的。 - "测试改动了用户数据源":SourceHolder.switchSource() 不落盘,只有 WeekScreen 的 rememberPreference(KEY_SOURCE_MODE) 会写偏好,SourceHolder 仅在 init 时读一次。 风险大于收益,暂不动 - XifanSource 分支 5 会把任意 iframe 的 src 原样当播放地址返回,导致分支 6 的 WebView 拦截兜底对这类页面失效。属实,但收紧接受条件很可能弄坏一个对着线上 站点手调的爬虫,需要真机验证再动。 - probeMediaSize 自建 DefaultHttpDataSource,与播放不共享缓存/连接池,HEAD 失败 会被静默吞成 null。属设计取舍,非缺陷。 验证 - app 模块 assembleDebug / assembleDebugAndroidTest / test 均通过 - assembleRelease 通过,并核对 mapping.txt:SourceMode 与 $ENTRIES 均为恒等映射、 7 个枚举常量无一被重命名 —— 即 -keep enum 规则生效,按名字持久化的数据在 混淆后的 release 中安全 - database_test 全包 instrumented 测试 31/31 通过 - Switch OLED (API 35) 安装启动无崩溃 - 注:danmaku:test 的 2 处失败为既有问题(Dispatchers.Main 未设置),与本次改动无关
站点 token 有效期实测为 7 天,之前没接 /auth/refresh,到点后播放必然 401,
用户被弹回登录页。现在在过期前把 token 换掉,持续使用的用户可以一直不掉线。
为什么是"提前"而不是"401 后补救"
- /api/auth/refresh 要求传入一个仍然有效的 token:拿无效/过期 token 调会返回
400 {"code":1001,"msg":"Invalid Token"}。也就是说它是"以旧换新"而不是
"过期后补救",放进 401 分支只会白调一次。所以必须在 401 之前动手。
续期时机:按剩余比例,而不是写死阈值
- 站点下发 expiresAt(实测是 9 位小数 + 偏移的 ISO 字符串,不是时间戳数字),
本地再记一个保存时刻,由此反推本次会话总时长,剩余不足一半时续期。
这样站点把有效期从 7 天改成 1 天时阈值会自动跟着缩,不会退化成"每次请求都续期"。
- 已过期 / 过期时间缺失 / 格式不认识 / savedAt 晚于 expiresAt 都返回 false:
前三种无法预判,最后一种说明设备时间被改过,此时宁可不续期也不要每请求一次。
- 续期失败保持原 token 不动,继续由 401 分支兜底,避免一次网络抖动就把用户登出。
登录脚本顺带把 expiresAt 取回来
- LOGIN_TOKEN_SCRIPT 原先只 return session.token,把 expiresAt 丢掉了,
这正是之前"无从判断该不该续期"的根因。
- 返回值改为 <token>|<expiresAt>,由 SourceAuthManager.parseLoginPayload 解析。
用 | 而不是 JSON 或换行分隔:WebAuthActivity 解码 evaluateJavascript 结果时
只剥掉 JSON 外层引号、不做反转义,换行会变成字面量 \n、内层引号会带反斜杠;
token 与 ISO 时间都不含 |,天然不需要转义。
其它
- 站点换发新 token 后旧 token 不会被吊销(实测换发后旧 token 仍返回 200),
所以续期不会把用户在其它设备上的登录踢掉。
- 注意响应字段是 snake_case 的 expires_at,与 localStorage 里的 expiresAt 不同名。
- SourceAuthManager.saveToken -> saveSession(WebAuthSession),一并保存过期时间与保存时刻;
clearToken 同时清掉这三项(老版本只存过 token,读不到过期时间时会退化成旧行为)。
- 删掉 CycanimeSource 里没人调用的 withoutSlash。
验证
- 用真实账号登录后实测:刷新接口返回 200 + 新 token,且换发后的 token 调
/api/v2/sections/{id}/play-url 拿到 200 + 真实地址(不带则 401)。
- 按 App 实际请求形状复刻验证通过:双斜杠 URL(baseUrl 末尾带 "/")、
空 urlencoded body、完整 baseHeaders。
- 新增 10 个 JVM 用例:载荷解析(含站点真实格式)与续期时机(刚保存/过半/临近/
已过期/缺过期时间/格式错误/时间戳倒置/有效期变更时阈值自动缩放)。
- assembleDebug / assembleRelease(R8)/ assembleDebugAndroidTest 通过;:app:test 25 项全绿。
原来 getVideoUrl 里有 6 条依次 return 的分支。拿三条线路的真实播放页
(/watch/3559/{1,2,3}/1.html,站点全部线路)逐条回放正则后,只有 1 条真正在干活:
- 分支1(取 player_aaaa 对象的 url):正则要求结尾是 `};`,页面里实际是 `}`,
永远匹配不上 —— 死代码。
- 分支3(取 "next" 字段):字段实际叫 url_next,`"next"` 匹配不到 —— 死代码。
- 分支4(整页扫直链,\/ 转义版与已解码版各一条):匹配到的是和分支2 完全相同的
字符串,而且永远不会被执行到。
- 分支5(iframe src):三条线路的页面里没有任何 iframe —— 从不命中。它还会把
非媒体的 iframe 地址(解析页/播放页)当成播放地址返回,并因此把分支6 挡掉,
比没有更糟。
- 分支6(WebView 嗅探兜底):前面一旦 return 就到不了;三条线路实测都不需要。
现在改为两步,两步都是活的:
1. 取站点播放配置里的 url 字段(当前这一集,最准确)
2. 兜底:整页扫任意直链(站点改模板时仍能找到)
做法上先整体反转义一次再匹配,于是「取字段」和「扫直链」都只需普通正则。
原来为转义形式单写的那条 `\\?/\\?/` 正则属于重复劳动 —— 它匹配到的就是
解码后完全相同的字符串。
顺带清理:
- 解析逻辑提为顶层 internal 函数 parseXifanVideoUrl,XifanSource 的 baseUrl
初始化需要 Context,整个 object 在单测里建不起来(与 GogoanimeSource 同样的
处理)。unescapeJson 一并提到顶层,object 内不再重复实现一份。
- 三条线路都解析不出时抛可读错误,而不是把空串交给播放器(那会变成
MalformedURLException 之类的难懂报错)。
- 删掉随之无用的 webViewUtil 字段与 onExit 覆写(与先前 GirigiriSource 的清理一致)。
验证
- 新增 8 个 JVM 用例,固定样本是三条线路真实页面的 player_aaaa 所在行
(共 1.8 KB),覆盖:带 %XX 的路径、带端口的 host、含中文与 \uXXXX 的 m3u8、
取本集而非 url_next、相对地址/普通链接不得误判、兜底路径。
- assembleDebug / assembleRelease(R8)/ assembleDebugAndroidTest 通过;:app:test 33 项全绿。
wdw89
force-pushed
the
pr/sources-reliability
branch
from
September 19, 2026 12:43
cd2c598 to
97131da
Compare
TV 的 D-pad 导航脚本会把页面上所有输入框置为 readOnly + inputmode=none, 原本只在用户按 OK 时由 __animiusAllowKb() 摘掉只读并重新聚焦来放开。手机/平板 走的是触摸聚焦,没有这条解开路径,输入框始终是只读,而 Chromium 不会为只读 输入框弹软键盘 —— 于是触摸点账号/密码框毫无反应。 按 isAndroidTV() 分流(复用 Util.kt 里既有的判定,不改判定标准): - 非 TV 不注入 TV_NAV_SCRIPT,也不清自动聚焦/滚回顶部,触摸走系统默认弹键盘。 - TvWebView 的 OK 键拦截(dispatchKeyEvent)与焦点轮询(startWatchingFocus) 只在 tvMode 生效。否则手机上软键盘的回车/换行/搜索会被 App 侧吞掉, 且输入框之间切换时会被强制收键盘。 - 脚本内再加一层 pointer:coarse / ontouchstart 兜底判断,TV 侧用 window.__animiusForceTv 强制覆盖,避免设备误判。 TV 上的行为不变:D-pad 上下移动 DOM 焦点 + OK 弹键盘。 顺带:脚本返回 already 时不再误报"注入失败"(页面内跳转时 onPageFinished 会 重复注入)。 验证 - assembleDebug 通过;Pixel Tablet (API 35) 装上后触摸点输入框直接弹键盘。 - :app:test 的 CycanimeSourceTest 通过。 - TV 侧只做了代码分流,未在 TV 模拟器上实测(按构造与改前路径一致)。 - 无合适的单测缝:软键盘弹出依赖 WebView + IME 的真机交互,只能真机验证。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
拆自同一批改动里与数据源 / 播放内核相关的部分。只动数据源解析、播放器内核与持久化兼容,不涉及界面结构。
1. 崩溃修复:失效数据源会让收藏 / 历史 / 下载页整页打不开
SourceMode在这轮里从 11 项收敛到 7 项,而收藏、历史、下载三张表存的是枚举名。三处读取点用的是裸SourceMode.valueOf(),老记录里的失效名字会抛IllegalArgumentException。关键点在于异常是从 Room 的
Flow里冒出来的 —— 它会把整个列表一起打挂,老用户打开这三个页面直接崩。改为
SourceMode.fromName()(查不到返回 null),三个 mapper 返回可空,调用点换mapNotNull。新增RemovedSourceRowTest:先断言原始行数是 2、再断言过滤后是 1,避免插入失败也能让测试假绿。2. 播放修复:HLS 与多线路
media3-extractor不含 HLS 提取器,旧代码对带请求头的地址一律走ProgressiveMediaSource,导致 m3u8 报UnrecognizedInputFormatException。现在按扩展名分派HlsMediaSource/ProgressiveMediaSource。VideoPlayerViewModel里_videoState.value.data!!的空指针:retry()会先置Resource.Loading(此时 data 为 null),失败页的「重试」与登录后的重试都会踩到。3. 恢复 Gogoanime
原实现依赖第三方代理
consumet-leox-api.vercel.app,该域名已被 DNS 污染(解析到 Meta 的 IP),官方api.consumet.org现在返回 451。改为直连站点自身链路(剧集页 → player 页 → megavid/source→ m3u8),不再需要任何代理。站点剧集是倒序的,补了
.reversed()与其它源对齐(lastPosition是按 URL 匹配的,不受影响)。4. 次元城(Cycanime)
/api/v2/sections/{id}/play-url,之前少了/v2,站点返回500 播放地址解析失败,很容易误判成站点故障。实际上匿名访问应返回 401,现在会给出明确的「需要登录」提示。登录流程沿用了你原有的验证码 WebView
次元城鉴权走 Bearer token(不是 cookie),所以这里没有新造机制,而是沿用你原来的
CaptchaWebViewActivity那套形状:WebView 打开站点页面 → 用户在页面里完成验证 / 登录 → 从 WebView 取回凭据 → 按数据源存到SharedPreferences→ 后续请求带上。改动点:
Authorization: Bearer,WebView 里能读到的凭据在localStorage的cycweb:auth:v2,不是 cookie。SourceAuthManager/WebAuthActivity,因为它的职责已经不只是验证码了。关于被删掉的 Cookie 那半:
CaptchaCookieManager.getCookies()/clearCookies()/syncFromWebView()原本只被 Cycanime 和 Girigiri 的 HTML 抓取调用(button.verify-submit那条检测路径)。这两个源改成 JSON API 之后,HTML 验证码弹窗不再存在,这条路径自然失去入口,所以一并删掉了 —— 不是判断你的代码无用,而是它依赖的解析方式已经不在了。同样,WebAuthActivity里的「验证码模式」(移动端 UA + 自动点击 JS + 已完成验证)也随之一并移除。token 过期:已接
/auth/refresh,提前续期而不是等掉线站点 token 的有效期实测为 7 天。这里做了主动续期,持续使用的用户可以一直不掉线。
关键约束是
/api/auth/refresh要求传入一个仍然有效的 token:200 {"code":0,"data":{"token":"...","expires_at":"..."}}400 {"code":1001,"msg":"Invalid Token"}也就是说它是「以旧换新」,不是「过期后补救」——放进 401 分支只会白调一次,所以只能在 401 之前动手。
因此续期时机按剩余比例而不是写死阈值:站点下发的
expiresAt连同本地保存时刻一起存下来,由此反推本次会话总时长,剩余不足一半时续期。这样站点把有效期从 7 天改成 1 天,阈值会自动跟着缩,不会退化成「每次请求都续期」。续期失败(网络异常 / 返回 1001)时保留原 token 不动,继续由 401 分支兜底,避免一次网络抖动就把用户登出。另外实测换发新 token 后旧 token 不会被吊销,所以续期不会把用户在其它设备上的登录踢掉。
LOGIN_TOKEN_SCRIPT相应地从「只返回 token」改为返回<token>|<expiresAt>—— 原先把expiresAt丢掉了,这正是之前无从判断该不该续期的根因。5. 清理
com/sakura/anime/...,在 main 上本来就是红的。encodeForUrl。SourceAuthManager的 pending 状态改为@Synchronized,修掉 check-then-act 的双消费竞态。page参数(JSON API / maccms suggest 本身没有分页),导致无限分页 + 重复行。CI 说明
两个 workflow 里失效的
Set up Android SDK步骤已在 #77 中移除。该 action(
android-actions/setup-android)会自行下载它绑定的 cmdline-tools 版本,而 Google已把这些 zip 下架,返回 404,job 在构建之前就挂掉;上游 issue
#536 自 8 月起开着,
最新版 v4 同样中招。本仓库在 2026-05-29 用同一份 workflow 是能通过的,
属于三方 action 随 Google 下架而失效,与代码改动无关。
本 PR 已重建在 #77 之上,会一并继承该修复。fork PR 的 workflow 默认待审批,
需要你点一次 Approve and run 才会实际跑。
};而永远匹配不上、1 条字段名写成next而站点其实是url_next、1 条与前者匹配到完全相同的字符串、1 条要求页面里有 iframe 但线上没有)。已收敛为「取播放配置的 url 字段 + 整页扫直链」两步,并补上三条线路真实页面的固定样本单测(89 KB → 1.8 KB,取player_aaaa所在的那一行)。验证
/api/v2/sections/{id}/play-url拿到 200 + 真实播放地址(不带则 401)。baseUrl末尾带/)、空 urlencoded body、完整baseHeaders。:app:assembleDebug/:app:assembleRelease(含 R8)/:app:assembleDebugAndroidTest均通过。release 包对照mapping.txt确认SourceMode与 7 个枚举常量均未被重命名(-keep enum规则生效),即按名字持久化的数据在混淆后的 release 中安全。:app:test→ 33 passed / 0 failed(新增 18 个用例:登录载荷解析、续期时机边界、有效期变更时阈值自动缩放,以及稀饭三条线路的播放地址解析)。database_test全包 instrumented 测试 31 / 31 通过。AllSourcesPlaybackTest保留在 androidTest,并在 TV 模拟器与平板设备上实机验证。:danmaku:test有 2 处既有失败(Dispatchers.Main未设置),与本次改动无关,未处理。